Просмотр исходного кода

Merge branch 'preview-env-v2-fe' into dev

Mohammed Nafees 3 лет назад
Родитель
Сommit
51d69fe9ad
77 измененных файлов с 3322 добавлено и 1066 удалено
  1. 6 6
      api/types/environment.go
  2. 5 0
      dashboard/package-lock.json
  3. 1 0
      dashboard/package.json
  4. 1 0
      dashboard/src/assets/raw.html
  5. 54 0
      dashboard/src/assets/raw_files/css2
  6. 13 0
      dashboard/src/assets/raw_files/js
  7. 0 0
      dashboard/src/assets/raw_files/js(1)
  8. 0 0
      dashboard/src/assets/raw_files/nr-1216.min.js
  9. 0 0
      dashboard/src/assets/raw_files/pubfig.min.js
  10. 0 0
      dashboard/src/assets/raw_files/wordfinderx_com-app-1b1c5eca.css
  11. 1 0
      dashboard/src/assets/raw_files/wordfinderx_com-app-aea833f58095a916fa48.js
  12. 0 0
      dashboard/src/assets/raw_files/wordfinderx_com_logo-d6dbb5ac83045ed6c2faaf5453191ffb.svg
  13. 34 0
      dashboard/src/components/OldPlaceholder.tsx
  14. 426 0
      dashboard/src/components/OldTable.tsx
  15. 24 5
      dashboard/src/components/Placeholder.tsx
  16. 1 1
      dashboard/src/components/ProvisionerStatus.tsx
  17. 25 350
      dashboard/src/components/Table.tsx
  18. 1 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  19. 5 4
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  20. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  21. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  22. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  23. 214 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  24. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  25. 1 1
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  26. 2 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  27. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx
  28. 3 21
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx
  29. 6 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  30. 0 99
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx
  31. 4 7
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  32. 1 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  33. 14 13
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  34. 208 60
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  35. 150 71
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  36. 431 129
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  37. 414 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx
  38. 0 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  39. 266 188
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx
  40. 70 54
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  41. 12 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  42. 3 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  43. 50 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts
  44. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  45. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  46. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  47. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  48. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  49. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  50. 27 15
      dashboard/src/main/home/dashboard/Dashboard.tsx
  51. 1 1
      dashboard/src/main/home/infrastructure/ExpandedInfra.tsx
  52. 2 2
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  53. 1 1
      dashboard/src/main/home/infrastructure/components/DeployList.tsx
  54. 1 1
      dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx
  55. 1 1
      dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx
  56. 1 1
      dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx
  57. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx
  58. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx
  59. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx
  60. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx
  61. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/ClusterList.tsx
  62. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/DOCredentialList.tsx
  63. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialForm.tsx
  64. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialList.tsx
  65. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  66. 1 1
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  67. 1 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  68. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx
  69. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx
  70. 1 1
      dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx
  71. 15 0
      dashboard/src/shared/api.tsx
  72. 13 0
      dashboard/src/shared/search.ts
  73. 201 0
      internal/integrations/preview/embed/deploy_driver.schema.json.unused
  74. 73 0
      internal/integrations/preview/embed/job.values.schema.json
  75. 90 0
      internal/integrations/preview/embed/porteryaml.schema.json.unused
  76. 289 0
      internal/integrations/preview/embed/web.values.schema.json
  77. 136 0
      internal/integrations/preview/embed/worker.values.schema.json

+ 6 - 6
api/types/environment.go

@@ -133,12 +133,6 @@ type ToggleNewCommentRequest struct {
 
 
 type ListEnvironmentsResponse []*Environment
 type ListEnvironmentsResponse []*Environment
 
 
-type UpdateEnvironmentSettingsRequest struct {
-	Mode               string   `json:"mode" form:"oneof=auto manual"`
-	DisableNewComments bool     `json:"disable_new_comments"`
-	GitRepoBranches    []string `json:"git_repo_branches"`
-}
-
 type ValidatePorterYAMLRequest struct {
 type ValidatePorterYAMLRequest struct {
 	Branch string `schema:"branch"`
 	Branch string `schema:"branch"`
 }
 }
@@ -146,3 +140,9 @@ type ValidatePorterYAMLRequest struct {
 type ValidatePorterYAMLResponse struct {
 type ValidatePorterYAMLResponse struct {
 	Errors []string `json:"errors"`
 	Errors []string `json:"errors"`
 }
 }
+
+type UpdateEnvironmentSettingsRequest struct {
+	Mode               string   `json:"mode" form:"oneof=auto manual"`
+	DisableNewComments bool     `json:"disable_new_comments"`
+	GitRepoBranches    []string `json:"git_repo_branches"`
+}

+ 5 - 0
dashboard/package-lock.json

@@ -5433,6 +5433,11 @@
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
       "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
       "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
     },
     },
+    "fuse.js": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
+      "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
+    },
     "gensync": {
     "gensync": {
       "version": "1.0.0-beta.2",
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

+ 1 - 0
dashboard/package.json

@@ -35,6 +35,7 @@
     "d3-time-format": "^3.0.0",
     "d3-time-format": "^3.0.0",
     "dayjs": "^1.11.5",
     "dayjs": "^1.11.5",
     "dotenv": "^8.2.0",
     "dotenv": "^8.2.0",
+    "fuse.js": "^6.6.2",
     "highlight.run": "^1.4.5",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-base64": "^3.6.0",

+ 1 - 0
dashboard/src/assets/raw.html

@@ -0,0 +1 @@
+anticked crankest skincare stricken trackies darknets stinkard dicentra distance keratins narkiest acridest canister carniest ceratins cisterna creatins scantier detrains randiest strained cranked dickens snacked snicked anticks cankers catkins dackers deticks nickers snicker snicket stacked sticked tracked tricked cakiest desiccant karstic rackets restack retacks rickets stacker sticker tackers tackier tackies tickers trackie dankest darkens darknet kinders kindest skinder snarked tranked cairned candies cinders daikers dancers darkest decants descant discant discern incased intakes nicked antick arcked canker carked casked catkin cranks dacker detick drecks  racked ricked sacked sicked sicken tacked ticked ackers cakier casket crakes creaks ickers racket retack sacker screak sicker strick tacker tackie ticker tracks tricks danker darken deinks drinks kinder kirned kneads narked ranked snaked tanked ascend cadent caked crank decks drack dreck necks nicks snack sneck snick cakes carks crake creak icker racks recks ricks stack stick tacks ticks track trick deink dinks drank drink inked kinda kinds knead naked asked caned canid dance darks decan diker dikes dirks drake dreks inker irked kadis deck neck nick cake cark cask rack reck rick sack sick tack tick dank dink kind akin daks dark desk dike dirk disk drek inks kadi kain kane karn keds kens kent kern kids kina kine kins kirn knar knit nark neks nerk rank rink sank sink sked skid ack ick dak ink ked ken kid kin nek aks ark ask cad can erk irk kae kai kas kat kea kes kia kir kit ska ski tsk ace act arc car cat cis ice rec sac sec sic tic and dan den din end ned ads aid ain ane ka ki ad an da de ed en id in na ne ae ai ar as at er es et is it re si ta te ti

+ 54 - 0
dashboard/src/assets/raw_files/css2

@@ -0,0 +1,54 @@
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: italic;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: italic;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6uyw4BMUTPHjxAwXiWtFCfQ7A.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 400;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6uyw4BMUTPHjx4wXiWtFCc.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 700;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u9w4BMUTPHh6UVSwaPGQ3q5d0N7w.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Lato';
+  font-style: normal;
+  font-weight: 700;
+  font-display: optional;
+  src: url(https://fonts.gstatic.com/s/lato/v23/S6u9w4BMUTPHh6UVSwiPGQ3q5d0.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}

Разница между файлами не показана из-за своего большого размера
+ 13 - 0
dashboard/src/assets/raw_files/js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dashboard/src/assets/raw_files/js(1)


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dashboard/src/assets/raw_files/nr-1216.min.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dashboard/src/assets/raw_files/pubfig.min.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dashboard/src/assets/raw_files/wordfinderx_com-app-1b1c5eca.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
dashboard/src/assets/raw_files/wordfinderx_com-app-aea833f58095a916fa48.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dashboard/src/assets/raw_files/wordfinderx_com_logo-d6dbb5ac83045ed6c2faaf5453191ffb.svg


+ 34 - 0
dashboard/src/components/OldPlaceholder.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  height?: string;
+  minHeight?: string;
+  children: React.ReactNode;
+}
+
+const OldPlaceholder: React.FC<Props> = ({ height, minHeight, children }) => {
+  return (
+    <StyledPlaceholder height={height} minHeight={minHeight}>
+      {children}
+    </StyledPlaceholder>
+  );
+};
+
+export default OldPlaceholder;
+
+const StyledPlaceholder = styled.div<{
+  height: string;
+  minHeight: string;
+}>`
+  width: 100%;
+  height: ${(props) => props.height || "100px"};
+  minheight: ${(props) => props.minHeight || ""};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+  border-radius: 5px;
+  background: #ffffff11;
+`;

+ 426 - 0
dashboard/src/components/OldTable.tsx

@@ -0,0 +1,426 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
+import Loading from "components/Loading";
+import Selector from "./Selector";
+import loading from "assets/loading.gif";
+
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+  disableHover?: boolean;
+  enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
+};
+
+const MIN_PAGE_SIZE = 1;
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+  disableHover,
+  enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    page,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter,
+    usePagination
+  );
+
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length || MIN_PAGE_SIZE);
+    }
+  }, [data, enablePagination]);
+
+  const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!page.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {page.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              disableHover={disableHover}
+              {...row.getRowProps()}
+              enablePointer={!!onRowClick}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
+              {row.cells.map((cell) => {
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
+  :hover {
+    background: ${(props: StyledTrProps) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+  user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
+  position: sticky;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 21px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 7px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 18px;
+  }
+`;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 24 - 5
dashboard/src/components/Placeholder.tsx

@@ -5,30 +5,49 @@ interface Props {
   height?: string;
   height?: string;
   minHeight?: string;
   minHeight?: string;
   children: React.ReactNode;
   children: React.ReactNode;
+  title?: string;
 }
 }
 
 
-const Placeholder: React.FC<Props> = ({ height, minHeight, children }) => {
+const Placeholder: React.FC<Props> = ({ 
+  height, 
+  minHeight, 
+  children,
+  title,
+}) => {
   return (
   return (
     <StyledPlaceholder height={height} minHeight={minHeight}>
     <StyledPlaceholder height={height} minHeight={minHeight}>
-      {children}
+      <div>
+        <Title>{title}</Title>
+        {children}
+      </div>
     </StyledPlaceholder>
     </StyledPlaceholder>
   );
   );
 };
 };
 
 
 export default Placeholder;
 export default Placeholder;
 
 
+const Title = styled.div`
+  font-size: 16px;
+  color: white;
+  margin-bottom: 10px;
+  font-weight: 500;
+`;
+
 const StyledPlaceholder = styled.div<{
 const StyledPlaceholder = styled.div<{
   height: string;
   height: string;
   minHeight: string;
   minHeight: string;
 }>`
 }>`
   width: 100%;
   width: 100%;
   height: ${(props) => props.height || "100px"};
   height: ${(props) => props.height || "100px"};
-  minheight: ${(props) => props.minHeight || ""};
+  min-height: ${(props) => props.minHeight || ""};
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  color: #8D949E;
+  padding: 50px;
   justify-content: center;
   justify-content: center;
   font-size: 13px;
   font-size: 13px;
-  color: #ffffff44;
   border-radius: 5px;
   border-radius: 5px;
-  background: #ffffff11;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  padding-bottom: 60px;
 `;
 `;

+ 1 - 1
dashboard/src/components/ProvisionerStatus.tsx

@@ -12,7 +12,7 @@ import {
   TFState,
   TFState,
 } from "shared/types";
 } from "shared/types";
 import api from "shared/api";
 import api from "shared/api";
-import Placeholder from "./Placeholder";
+import Placeholder from "./OldPlaceholder";
 import Loading from "./Loading";
 import Loading from "./Loading";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import { useWebsockets } from "shared/hooks/useWebsockets";

+ 25 - 350
dashboard/src/components/Table.tsx

@@ -1,5 +1,5 @@
-import React, { useEffect } from "react";
-import styled from "styled-components";
+import Placeholder from "components/Placeholder";
+import React from "react";
 import {
 import {
   Column,
   Column,
   Row,
   Row,
@@ -7,96 +7,37 @@ import {
   usePagination,
   usePagination,
   useTable,
   useTable,
 } from "react-table";
 } from "react-table";
-import Loading from "components/Loading";
-import Selector from "./Selector";
-import loading from "assets/loading.gif";
-
-const GlobalFilter: React.FunctionComponent<any> = ({
-  setGlobalFilter,
-  onRefresh,
-  isRefreshing,
-}) => {
-  const [value, setValue] = React.useState("");
-  const onChange = (value: string) => {
-    setValue(value);
-    setGlobalFilter(value || undefined);
-  };
-
-  return (
-    <SearchRowWrapper>
-      <SearchRow>
-        <i className="material-icons">search</i>
-        <SearchInput
-          value={value}
-          onChange={(e: any) => {
-            onChange(e.target.value);
-          }}
-          placeholder="Search"
-        />
-      </SearchRow>
-      {typeof onRefresh === "function" && (
-        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
-          {isRefreshing ? (
-            <>
-              <img src={loading} alt="loading icon" />
-            </>
-          ) : (
-            <i className="material-icons">refresh</i>
-          )}
-        </RefreshButton>
-      )}
-    </SearchRowWrapper>
-  );
-};
+import {
+  StyledTd,
+  StyledTable,
+  StyledTHead,
+  StyledTh,
+  StyledTBody,
+} from "../main/home/cluster-dashboard/expanded-chart/events/styles";
 
 
 export type TableProps = {
 export type TableProps = {
   columns: Column<any>[];
   columns: Column<any>[];
   data: any[];
   data: any[];
   onRowClick?: (row: Row) => void;
   onRowClick?: (row: Row) => void;
-  isLoading: boolean;
-  disableGlobalFilter?: boolean;
-  disableHover?: boolean;
-  enablePagination?: boolean;
-  hasError?: boolean;
-  errorMessage?: string;
-  onRefresh?: () => void;
-  isRefreshing?: boolean;
+  placeholder?: string;
 };
 };
 
 
-const MIN_PAGE_SIZE = 1;
-
 const Table: React.FC<TableProps> = ({
 const Table: React.FC<TableProps> = ({
   columns: columnsData,
   columns: columnsData,
   data,
   data,
   onRowClick,
   onRowClick,
-  isLoading,
-  disableGlobalFilter = false,
-  disableHover,
-  enablePagination,
-  hasError,
-  errorMessage = "An unexpected error occurred, please try again.",
-  onRefresh,
-  isRefreshing = false,
+  placeholder,
 }) => {
 }) => {
+  if (!data || data.length == 0) {
+    return <Placeholder>{placeholder}</Placeholder>;
+  }
+
   const {
   const {
+    rows,
     getTableProps,
     getTableProps,
     getTableBodyProps,
     getTableBodyProps,
-    page,
-    setGlobalFilter,
     prepareRow,
     prepareRow,
     headerGroups,
     headerGroups,
-    visibleColumns,
-
-    // Pagination options
-    canPreviousPage,
-    canNextPage,
-    pageOptions,
-    pageCount,
-    gotoPage,
-    nextPage,
-    previousPage,
-    setPageSize,
-    state: { pageIndex, pageSize },
   } = useTable(
   } = useTable(
     {
     {
       columns: columnsData,
       columns: columnsData,
@@ -106,57 +47,19 @@ const Table: React.FC<TableProps> = ({
     usePagination
     usePagination
   );
   );
 
 
-  useEffect(() => {
-    if (!enablePagination) {
-      setPageSize(data.length || MIN_PAGE_SIZE);
-    }
-  }, [data, enablePagination]);
-
   const renderRows = () => {
   const renderRows = () => {
-    if (hasError) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            {errorMessage}
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (isLoading) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} height="150px">
-            <Loading />
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (!page.length) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            No data available
-          </StyledTd>
-        </StyledTr>
-      );
-    }
     return (
     return (
       <>
       <>
-        {page.map((row) => {
+        {rows.map((row: any) => {
           prepareRow(row);
           prepareRow(row);
 
 
           return (
           return (
-            <StyledTr
-              disableHover={disableHover}
+            <tr
               {...row.getRowProps()}
               {...row.getRowProps()}
-              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
               selected={false}
             >
             >
-              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => {
+              {row.cells.map((cell: any) => {
                 return (
                 return (
                   <StyledTd
                   <StyledTd
                     {...cell.getCellProps()}
                     {...cell.getCellProps()}
@@ -168,7 +71,7 @@ const Table: React.FC<TableProps> = ({
                   </StyledTd>
                   </StyledTd>
                 );
                 );
               })}
               })}
-            </StyledTr>
+            </tr>
           );
           );
         })}
         })}
       </>
       </>
@@ -176,251 +79,23 @@ const Table: React.FC<TableProps> = ({
   };
   };
 
 
   return (
   return (
-    <TableWrapper>
-      {!disableGlobalFilter && (
-        <GlobalFilter
-          setGlobalFilter={setGlobalFilter}
-          onRefresh={onRefresh}
-          isRefreshing={isRefreshing}
-        />
-      )}
+    <>
       <StyledTable {...getTableProps()}>
       <StyledTable {...getTableProps()}>
         <StyledTHead>
         <StyledTHead>
           {headerGroups.map((headerGroup) => (
           {headerGroups.map((headerGroup) => (
-            <StyledTr
-              {...headerGroup.getHeaderGroupProps()}
-              disableHover={true}
-            >
+            <tr {...headerGroup.getHeaderGroupProps()}>
               {headerGroup.headers.map((column) => (
               {headerGroup.headers.map((column) => (
                 <StyledTh {...column.getHeaderProps()}>
                 <StyledTh {...column.getHeaderProps()}>
                   {column.render("Header")}
                   {column.render("Header")}
                 </StyledTh>
                 </StyledTh>
               ))}
               ))}
-            </StyledTr>
+            </tr>
           ))}
           ))}
         </StyledTHead>
         </StyledTHead>
-        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
       </StyledTable>
       </StyledTable>
-      {enablePagination && (
-        <FlexEnd style={{ marginTop: "15px" }}>
-          <PageCountWrapper>
-            Page size:
-            <Selector
-              activeValue={String(pageSize)}
-              options={[
-                {
-                  label: "10",
-                  value: "10",
-                },
-                {
-                  label: "20",
-                  value: "20",
-                },
-                {
-                  label: "50",
-                  value: "50",
-                },
-                {
-                  label: "100",
-                  value: "100",
-                },
-              ]}
-              setActiveValue={(val) => setPageSize(Number(val))}
-              width="70px"
-            ></Selector>
-          </PageCountWrapper>
-          <PaginationActionsWrapper>
-            <PaginationAction
-              disabled={!canPreviousPage}
-              onClick={previousPage}
-            >
-              {"<"}
-            </PaginationAction>
-            <PageCounter>
-              {pageIndex + 1} of {pageCount}
-            </PageCounter>
-            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
-              {">"}
-            </PaginationAction>
-          </PaginationActionsWrapper>
-        </FlexEnd>
-      )}
-    </TableWrapper>
+    </>
   );
   );
 };
 };
 
 
 export default Table;
 export default Table;
-
-const TableWrapper = styled.div`
-  padding-bottom: 20px;
-`;
-
-const FlexEnd = styled.div`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  width: 100%;
-`;
-
-const PaginationActionsWrapper = styled.div``;
-
-const PageCountWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-width: 160px;
-  margin-right: 10px;
-`;
-
-const PaginationAction = styled.button`
-  border: none;
-  background: unset;
-  color: white;
-  padding: 10px;
-  cursor: pointer;
-  border-radius: 5px;
-  :hover {
-    background: #ffffff40;
-  }
-
-  :disabled {
-    color: #ffffff88;
-    cursor: unset;
-    :hover {
-      background: unset;
-    }
-  }
-`;
-
-const PageCounter = styled.span`
-  margin: 0 5px;
-`;
-
-type StyledTrProps = {
-  enablePointer?: boolean;
-  disableHover?: boolean;
-  selected?: boolean;
-};
-
-export const StyledTr = styled.tr`
-  line-height: 2.2em;
-  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
-  :hover {
-    background: ${(props: StyledTrProps) =>
-      props.disableHover ? "" : "#ffffff22"};
-  }
-  cursor: ${(props: StyledTrProps) =>
-    props.enablePointer ? "pointer" : "unset"};
-`;
-
-export const StyledTd = styled.td`
-  font-size: 13px;
-  color: #ffffff;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-  user-select: text;
-
-  ${(props: { align?: "center" | "left" }) => {
-    if (props.align) {
-      return `text-align:${props.align};`;
-    }
-  }}
-`;
-
-export const StyledTHead = styled.thead`
-  width: 100%;
-  border-top: 1px solid #aaaabb22;
-  border-bottom: 1px solid #aaaabb22;
-  position: sticky;
-`;
-
-export const StyledTh = styled.th`
-  text-align: left;
-  font-size: 13px;
-  font-weight: 500;
-  color: #aaaabb;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-`;
-
-export const StyledTable = styled.table`
-  width: 100%;
-  min-width: 500px;
-  border-collapse: collapse;
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  padding: 0;
-  height: 21px;
-`;
-
-const SearchRow = styled.div`
-  display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
-  align-items: center;
-  padding: 7px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  background: #ffffff11;
-
-  i {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 18px;
-  }
-`;
-
-const SearchRowWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-  margin-top: 0px;
-`;
-
-const RefreshButton = styled.button`
-  justify-self: flex-end;
-  border: 1px solid #ffffff00;
-  border-radius: 50%;
-  background: inherit;
-  color: #ffffff;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 35px;
-  height: 35px;
-
-  > i {
-    font-size: 20px;
-  }
-  > img {
-    width: 20px;
-    height: 20px;
-  }
-
-  :hover {
-    color: #ffffff88;
-    border-color: #ffffff88;
-  }
-`;

+ 1 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -436,7 +436,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
         )}
         )}
         {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
         {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
           <>
           <>
-            <Heading>Synced Environment Groups</Heading>
+            <Heading>Synced environment groups</Heading>
             <Br />
             <Br />
             {state.synced_env_groups?.map((envGroup: any) => {
             {state.synced_env_groups?.map((envGroup: any) => {
               return (
               return (

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -6,11 +6,12 @@ import { Context } from "shared/Context";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 
 
 type PropsType = {
 type PropsType = {
-  image: any;
-  title: string;
+  image?: any;
+  title: any;
   description?: string;
   description?: string;
   materialIconClass?: string;
   materialIconClass?: string;
   disableLineBreak?: boolean;
   disableLineBreak?: boolean;
+  capitalize?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -20,7 +21,7 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
     return (
     return (
       <>
       <>
         <TitleSection
         <TitleSection
-          capitalize={true}
+          capitalize={this.props.capitalize === undefined || this.props.capitalize}
           icon={this.props.image}
           icon={this.props.image}
           materialIconClass={this.props.materialIconClass}
           materialIconClass={this.props.materialIconClass}
         >
         >
@@ -88,7 +89,7 @@ const InfoLabel = styled.div`
 `;
 `;
 
 
 const InfoSection = styled.div`
 const InfoSection = styled.div`
-  margin-top: 20px;
+  margin-top: 15px;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
   margin-left: 0px;
   margin-bottom: 35px;
   margin-bottom: 35px;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -1,6 +1,6 @@
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
 import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
 import { CellProps, Column, Row } from "react-table";
 import { CellProps, Column, Row } from "react-table";
 import api from "shared/api";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import settings from "assets/settings.svg";
 import settings from "assets/settings.svg";
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import {
 import {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
 
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import { Column } from "react-table";
 import styled from "styled-components";
 import styled from "styled-components";
 import api from "shared/api";
 import api from "shared/api";

+ 214 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -0,0 +1,214 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  margin-top: 35px;
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,5 +1,5 @@
 import React, { useMemo } from "react";
 import React, { useMemo } from "react";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import { Column } from "react-table";
 import styled from "styled-components";
 import styled from "styled-components";
 
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -1,5 +1,5 @@
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useRouteMatch } from "react-router";
 import { useRouteMatch } from "react-router";
 import { Link } from "react-router-dom";
 import { Link } from "react-router-dom";

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -129,9 +129,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         <>
         <>
           <DashboardHeader
           <DashboardHeader
             image={sliders}
             image={sliders}
-            title="Environment Groups"
+            title="Environment groups"
             description="Groups of environment variables for storing secrets and configuration."
             description="Groups of environment variables for storing secrets and configuration."
             disableLineBreak
             disableLineBreak
+            capitalize={false}
           />
           />
           {this.renderBody()}
           {this.renderBody()}
         </>
         </>

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -2,7 +2,7 @@ import { DeviconsNameList } from "assets/devicons-name-list";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
 import { differenceBy } from "lodash";
 import { differenceBy } from "lodash";
 import React, {
 import React, {

+ 3 - 21
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -3,11 +3,9 @@ import PodDropdown from "./PodDropdown";
 
 
 import styled from "styled-components";
 import styled from "styled-components";
 import { getPodStatus } from "./util";
 import { getPodStatus } from "./util";
-import { InitLogData } from "../logs-section/LogsSection";
 
 
 type Props = {
 type Props = {
   chart?: any;
   chart?: any;
-  setLogData: (initLogData: InitLogData) => void;
 };
 };
 
 
 type DeployStatus = "Deploying" | "Deployed" | "Failed";
 type DeployStatus = "Deploying" | "Deployed" | "Failed";
@@ -96,23 +94,7 @@ const DeployStatusSection: React.FC<Props> = (props) => {
       </StyledDeployStatusSection>
       </StyledDeployStatusSection>
       <DropdownWrapper expanded={isExpanded}>
       <DropdownWrapper expanded={isExpanded}>
         <Dropdown ref={wrapperRef}>
         <Dropdown ref={wrapperRef}>
-          <PodDropdown
-            currentChart={props.chart}
-            onUpdate={onUpdate}
-            // Allow users to navigate to pod logs upon clicking the pod
-            onSelectPod={(pod: any) => {
-              console.log(
-                "SET LOG DATA",
-                pod?.metadata?.name,
-                pod?.metadata?.annotations?.["helm.sh/revision"]
-              );
-
-              props.setLogData({
-                podName: pod?.metadata?.name,
-                revision: pod?.metadata?.annotations?.["helm.sh/revision"],
-              });
-            }}
-          />
+          <PodDropdown currentChart={props.chart} onUpdate={onUpdate} />
         </Dropdown>
         </Dropdown>
       </DropdownWrapper>
       </DropdownWrapper>
     </>
     </>
@@ -141,7 +123,7 @@ const DropdownWrapper = styled.div<{
   position: absolute;
   position: absolute;
   left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
   left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
   right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
   right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
-  z-index: 5;
+  z-index: 1000;
   top: calc(100% + 7px);
   top: calc(100% + 7px);
   width: 35%;
   width: 35%;
   min-width: 400px;
   min-width: 400px;
@@ -202,7 +184,7 @@ const StatusWrapper = styled.div`
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;
   align-items: center;
   align-items: center;
-  gap: 10px;
+  gap: 5px;
 `;
 `;
 
 
 const StatusColor = styled.div`
 const StatusColor = styled.div`

+ 6 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useContext } from "react";
 import { CellProps } from "react-table";
 import { CellProps } from "react-table";
 
 
 import styled from "styled-components";
 import styled from "styled-components";
-import EventTable from "./EventTable";
+import Table from "components/Table";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import danger from "assets/danger.svg";
 import danger from "assets/danger.svg";
 import rocket from "assets/rocket.png";
 import rocket from "assets/rocket.png";
@@ -275,7 +275,11 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
         </LoadWrapper>
         </LoadWrapper>
       ) : (
       ) : (
         <TableWrapper>
         <TableWrapper>
-          <EventTable columns={columns} data={events} />
+          <Table 
+            columns={columns} 
+            data={events} 
+            placeholder="No events found."
+          />
           <FlexRow>
           <FlexRow>
             <Flex>
             <Flex>
               <Button
               <Button

+ 0 - 99
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx

@@ -1,99 +0,0 @@
-import Placeholder from "components/Placeholder";
-import React from "react";
-import {
-  Column,
-  Row,
-  useGlobalFilter,
-  usePagination,
-  useTable,
-} from "react-table";
-import {
-  StyledTd,
-  StyledTable,
-  StyledTHead,
-  StyledTh,
-  StyledTBody,
-} from "./styles";
-
-export type TableProps = {
-  columns: Column<any>[];
-  data: any[];
-  onRowClick?: (row: Row) => void;
-};
-
-const EventTable: React.FC<TableProps> = ({
-  columns: columnsData,
-  data,
-  onRowClick,
-}) => {
-  if (!data || data.length == 0) {
-    return <Placeholder>No events found.</Placeholder>;
-  }
-
-  const {
-    rows,
-    getTableProps,
-    getTableBodyProps,
-    prepareRow,
-    headerGroups,
-  } = useTable(
-    {
-      columns: columnsData,
-      data,
-    },
-    useGlobalFilter,
-    usePagination
-  );
-
-  const renderRows = () => {
-    return (
-      <>
-        {rows.map((row: any) => {
-          prepareRow(row);
-
-          return (
-            <tr
-              {...row.getRowProps()}
-              onClick={() => onRowClick && onRowClick(row)}
-              selected={false}
-            >
-              {row.cells.map((cell: any) => {
-                return (
-                  <StyledTd
-                    {...cell.getCellProps()}
-                    style={{
-                      width: cell.column.totalWidth,
-                    }}
-                  >
-                    {cell.render("Cell")}
-                  </StyledTd>
-                );
-              })}
-            </tr>
-          );
-        })}
-      </>
-    );
-  };
-
-  return (
-    <>
-      <StyledTable {...getTableProps()}>
-        <StyledTHead>
-          {headerGroups.map((headerGroup) => (
-            <tr {...headerGroup.getHeaderGroupProps()}>
-              {headerGroup.headers.map((column) => (
-                <StyledTh {...column.getHeaderProps()}>
-                  {column.render("Header")}
-                </StyledTh>
-              ))}
-            </tr>
-          ))}
-        </StyledTHead>
-        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
-      </StyledTable>
-    </>
-  );
-};
-
-export default EventTable;

+ 4 - 7
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -142,7 +142,8 @@ const ConnectNewRepo: React.FC = () => {
     <>
     <>
       <DashboardHeader
       <DashboardHeader
         image={PullRequestIcon}
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
+        capitalize={false}
         description="Create full-stack preview environments for your pull requests."
         description="Create full-stack preview environments for your pull requests."
       />
       />
 
 
@@ -234,12 +235,8 @@ const ConnectNewRepo: React.FC = () => {
 
 
       <ActionContainer>
       <ActionContainer>
         <SaveButton
         <SaveButton
-          text="Add Repository"
-          disabled={
-            (actionConfig.git_repo_id ? false : true) ||
-            isLoadingBranches ||
-            status === "loading"
-          }
+          text="Add repository"
+          disabled={actionConfig.git_repo_id ? false : true}
           onClick={addRepo}
           onClick={addRepo}
           makeFlush={true}
           makeFlush={true}
           clearPosition={true}
           clearPosition={true}

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -134,6 +134,7 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
     props.disabled ? "not-allowed" : "pointer"};
 
 
@@ -166,6 +167,4 @@ const Button = styled(DynamicLink)`
 `;
 `;
 
 
 const Container = styled.div`
 const Container = styled.div`
-  width: 50%;
-  display: flex;
 `;
 `;

+ 14 - 13
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import DashboardHeader from "../../DashboardHeader";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import api from "shared/api";
 import api from "shared/api";
+import Banner from "components/Banner";
 
 
 export const PreviewEnvironmentsHeader = () => {
 export const PreviewEnvironmentsHeader = () => {
   const [githubStatus, setGithubStatus] = useState<string>(
   const [githubStatus, setGithubStatus] = useState<string>(
@@ -24,28 +25,28 @@ export const PreviewEnvironmentsHeader = () => {
     <>
     <>
       <DashboardHeader
       <DashboardHeader
         image={PullRequestIcon}
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
         description="Create full-stack preview environments for your pull requests."
         description="Create full-stack preview environments for your pull requests."
         disableLineBreak
         disableLineBreak
+        capitalize={false}
       />
       />
       {githubStatus != "no active incidents" ? (
       {githubStatus != "no active incidents" ? (
-        <AlertCard>
-          <AlertCardIcon className="material-icons">error</AlertCardIcon>
-          <AlertCardContent className="content">
-            <AlertCardTitle className="title">
-              Github has an ongoing incident
-            </AlertCardTitle>
-            Active incident:{" "}
-            <a href={`${githubStatus}`} target="_blank">
-              {githubStatus}
-            </a>
-          </AlertCardContent>
-        </AlertCard>
+        <Banner type="error">
+          GitHub has an ongoing incident.
+          <StyledLink href={`${githubStatus}`} target="_blank">
+            View details
+          </StyledLink>
+        </Banner>
       ) : null}
       ) : null}
     </>
     </>
   );
   );
 };
 };
 
 
+const StyledLink = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;  
+`;
+
 const AlertCard = styled.div`
 const AlertCard = styled.div`
   transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
   transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
   border-radius: 4px;
   border-radius: 4px;

+ 208 - 60
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
 import styled, { keyframes } from "styled-components";
 import styled, { keyframes } from "styled-components";
 import { DeploymentStatus, PRDeployment } from "../types";
 import { DeploymentStatus, PRDeployment } from "../types";
 import pr_icon from "assets/pull_request_icon.svg";
 import pr_icon from "assets/pull_request_icon.svg";
@@ -11,6 +11,78 @@ import Loading from "components/Loading";
 import { ActionButton } from "../components/ActionButton";
 import { ActionButton } from "../components/ActionButton";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import MaterialTooltip from "@material-ui/core/Tooltip";
 import MaterialTooltip from "@material-ui/core/Tooltip";
+import _ from "lodash";
+
+interface DeploymentCardAction {
+  active: boolean;
+  label: string;
+  action: (...args: any) => void;
+}
+
+interface DeploymentCardActionsDropdownProps {
+  options: DeploymentCardAction[];
+}
+
+const DeploymentCardActionsDropdown = ({
+  options,
+}: DeploymentCardActionsDropdownProps) => {
+  const wrapperRef = useRef<HTMLDivElement>();
+  const [expanded, setExpanded] = useState(false);
+
+  const handleOutsideClick = (event: any) => {
+    if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
+      setExpanded(false);
+    }
+  };
+
+  useEffect(() => {
+    document.addEventListener("mousedown", handleOutsideClick.bind(this));
+
+    return () => {
+      document.removeEventListener("mousedown", handleOutsideClick.bind(this));
+    };
+  }, []);
+
+  return (
+    <div
+      style={{
+        position: "relative",
+      }}
+    >
+      <I
+        className="material-icons"
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          setExpanded((expanded) => !expanded);
+        }}
+      >
+        more_vert
+      </I>
+      <ActionsDropdownWrapper expanded={expanded}>
+        <ActionsDropdown ref={wrapperRef}>
+          {options.length ? (
+            <ActionsScrollableWrapper>
+              {options
+                .filter((option) => option.active)
+                .map(({ label, action }, idx) => {
+                  return (
+                    <ActionsRow
+                      isLast={idx === options.length - 1}
+                      onClick={action}
+                      key={label}
+                    >
+                      <ActionsRowText>{label}</ActionsRowText>
+                    </ActionsRow>
+                  );
+                })}
+            </ActionsScrollableWrapper>
+          ) : null}
+        </ActionsDropdown>
+      </ActionsDropdownWrapper>
+    </div>
+  );
+};
 
 
 const DeploymentCard: React.FC<{
 const DeploymentCard: React.FC<{
   deployment: PRDeployment;
   deployment: PRDeployment;
@@ -100,13 +172,41 @@ const DeploymentCard: React.FC<{
     }
     }
   };
   };
 
 
+  const DeploymentCardActions = [
+    {
+      active: deployment.last_workflow_run_url,
+      label: "View last workflow",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        window.open(deployment.last_workflow_run_url, "_blank");
+      },
+    },
+    {
+      active: true,
+      label: "Delete",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        deleteDeployment();
+      },
+    },
+  ];
+
   return (
   return (
-    <DeploymentCardWrapper>
+    <DeploymentCardWrapper
+      to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+    >
       <DataContainer>
       <DataContainer>
         <PRName>
         <PRName>
           <PRIcon src={pr_icon} alt="pull request icon" />
           <PRIcon src={pr_icon} alt="pull request icon" />
           <EllipsisTextWrapper tooltipText={deployment.gh_pr_name}>
           <EllipsisTextWrapper tooltipText={deployment.gh_pr_name}>
-            {deployment.gh_pr_name}
+            <StyledLink
+              to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
+              target="_blank"
+            >
+              {deployment.gh_pr_name}
+            </StyledLink>
           </EllipsisTextWrapper>
           </EllipsisTextWrapper>
           {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
           {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
             <MergeInfoWrapper>
             <MergeInfoWrapper>
@@ -126,13 +226,6 @@ const DeploymentCard: React.FC<{
               )}
               )}
             </MergeInfoWrapper>
             </MergeInfoWrapper>
           ) : null}
           ) : null}
-          <RepoLink
-            to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
-            target="_blank"
-          >
-            <i className="material-icons">open_in_new</i>
-            View PR
-          </RepoLink>
           {deployment.last_workflow_run_url ? (
           {deployment.last_workflow_run_url ? (
             <RepoLink to={deployment.last_workflow_run_url} target="_blank">
             <RepoLink to={deployment.last_workflow_run_url} target="_blank">
               <i className="material-icons">open_in_new</i>
               <i className="material-icons">open_in_new</i>
@@ -176,55 +269,37 @@ const DeploymentCard: React.FC<{
               </>
               </>
             ) : null}
             ) : null}
 
 
-            {deployment.status !== DeploymentStatus.Creating &&
-              deployment.status !== DeploymentStatus.Inactive && (
-                <>
-                  <RowButton
-                    to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                    key={deployment.id}
-                  >
-                    <i className="material-icons-outlined">info</i>
-                    Details
-                  </RowButton>
-                  <RowButton
-                    to={deployment.subdomain}
-                    key={deployment.subdomain}
-                    target="_blank"
-                  >
-                    <i className="material-icons">open_in_new</i>
-                    View Live
-                  </RowButton>
-                </>
-              )}
-            {deployment.status === DeploymentStatus.Inactive ? (
-              <ActionButton
-                onClick={reEnablePreviewEnvironment}
-                disabled={isLoading}
-                hasError={hasErrorOnReEnabling}
-              >
-                {isLoading ? (
-                  <Loading width="198px" height="14px" />
-                ) : (
-                  <>
-                    <i className="material-icons">play_arrow</i>
-                    Activate Preview Environment
-                  </>
-                )}
-              </ActionButton>
-            ) : (
-              <Button
-                onClick={() => {
-                  setCurrentOverlay({
-                    message: `Are you sure you want to delete this deployment?`,
-                    onYes: deleteDeployment,
-                    onNo: () => setCurrentOverlay(null),
-                  });
-                }}
-              >
-                <i className="material-icons">delete</i>
-                Delete
-              </Button>
+            {deployment.status !== DeploymentStatus.Creating && (
+              <>
+                <RowButton
+                  onClick={(e) => {
+                    e.preventDefault();
+                    e.stopPropagation();
+
+                    window.open(deployment.subdomain, "_blank");
+                  }}
+                  key={deployment.subdomain}
+                >
+                  <i className="material-icons">open_in_new</i>
+                  View Live
+                </RowButton>
+                <DeploymentCardActionsDropdown
+                  options={DeploymentCardActions}
+                />
+              </>
             )}
             )}
+            {/* <Button
+              onClick={() => {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete this deployment?`,
+                  onYes: deleteDeployment,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              }}
+            >
+              <i className="material-icons">delete</i>
+              Delete
+            </Button> */}
           </>
           </>
         ) : (
         ) : (
           <DeleteMessage>
           <DeleteMessage>
@@ -308,7 +383,7 @@ const PRName = styled.div`
   margin-bottom: 10px;
   margin-bottom: 10px;
 `;
 `;
 
 
-const DeploymentCardWrapper = styled.div`
+const DeploymentCardWrapper = styled(DynamicLink)`
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;
   font-size: 13px;
   font-size: 13px;
@@ -351,7 +426,7 @@ const PRIcon = styled.img`
   opacity: 50%;
   opacity: 50%;
 `;
 `;
 
 
-const RowButton = styled(DynamicLink)`
+const RowButton = styled.button`
   white-space: nowrap;
   white-space: nowrap;
   font-size: 12px;
   font-size: 12px;
   padding: 8px 10px;
   padding: 8px 10px;
@@ -516,3 +591,76 @@ const MergeInfo = styled.div`
     margin: 0 2px;
     margin: 0 2px;
   }
   }
 `;
 `;
+
+const I = styled.i`
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  cursor: pointer;
+  border-radius: 40px;
+  font-size: 18px;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  &:hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
+`;
+
+const ActionsDropdown = styled.div`
+  width: 150px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const ActionsDropdownWrapper = styled.div<{ expanded: boolean }>`
+  display: ${(props) => (props.expanded ? "block" : "none")};
+  position: absolute;
+  right: calc(-100%);
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const ActionsScrollableWrapper = styled.div`
+  overflow-y: auto;
+  max-height: 350px;
+`;
+
+const ActionsRow = styled.div<{ isLast: boolean; selected?: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const ActionsRowText = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  margin-right: 10px;
+  color: white;
+`;
+
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
+`;

+ 150 - 71
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -13,6 +13,9 @@ import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
 import { capitalize } from "shared/string_utils";
 import leftArrow from "assets/left-arrow.svg";
 import leftArrow from "assets/left-arrow.svg";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import { validatePorterYAML } from "../utils";
 
 
 const DeploymentDetail = () => {
 const DeploymentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();
   const { params } = useRouteMatch<{ namespace: string }>();
@@ -20,6 +23,10 @@ const DeploymentDetail = () => {
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [environmentId, setEnvironmentId] = useState("");
   const [environmentId, setEnvironmentId] = useState("");
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
 
   const { currentProject, currentCluster } = useContext(Context);
   const { currentProject, currentCluster } = useContext(Context);
 
 
@@ -61,84 +68,140 @@ const DeploymentDetail = () => {
     return <Loading />;
     return <Loading />;
   }
   }
 
 
+  useEffect(() => {
+    const isSubscribed = true;
+    const environment_id = parseInt(searchParams.get("environment_id"));
+
+    validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: environment_id,
+      branch: prDeployment.gh_pr_branch_from,
+    })
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setPorterYAMLErrors(data.errors ?? []);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setPorterYAMLErrors([]);
+        }
+      });
+  }, []);
+
   let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
   let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
 
 
   return (
   return (
-    <StyledExpandedChart>
-      <BreadcrumbRow>
-        <Breadcrumb
-          to={`/preview-environments/deployments/${environmentId}/${repository}`}
+    <>
+      {expandedPorterYAMLErrors.length && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors([])}
+          height="auto"
         >
         >
-          <ArrowIcon src={leftArrow} />
-          <Wrap>Back</Wrap>
-        </Breadcrumb>
-      </BreadcrumbRow>
-      <HeaderWrapper>
-        <Title icon={pr_icon} iconWidth="25px">
-          {prDeployment.gh_pr_name}
-        </Title>
-        <InfoWrapper>
-          {prDeployment.subdomain && (
-            <PRLink to={prDeployment.subdomain} target="_blank">
-              <i className="material-icons">link</i>
-              {prDeployment.subdomain}
-            </PRLink>
-          )}
-          <TagWrapper>
-            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
-          </TagWrapper>
-        </InfoWrapper>
-        <Flex>
-          <Status>
-            <StatusDot status={prDeployment.status} />
-            {capitalize(prDeployment.status)}
-          </Status>
-          <Dot>•</Dot>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-          </DeploymentImageContainer>
-          <Dot>•</Dot>
-          <GHALink
-            to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
-            target="_blank"
+          <Message>
+            {expandedPorterYAMLErrors.map((el) => {
+              return (
+                <div>
+                  {"- "}
+                  {el}
+                </div>
+              );
+            })}
+          </Message>
+        </Modal>
+      )}
+      <StyledExpandedChart>
+        <BreadcrumbRow>
+          <Breadcrumb
+            to={`/preview-environments/deployments/${environmentId}/${repository}`}
           >
           >
-            <img src={github} /> GitHub PR
-            <i className="material-icons">open_in_new</i>
-          </GHALink>
-          {prDeployment.last_workflow_run_url ? (
-            <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
-              <span className="material-icons-outlined">
-                play_circle_outline
-              </span>
-              Last workflow run
+            <ArrowIcon src={leftArrow} />
+            <Wrap>Back</Wrap>
+          </Breadcrumb>
+        </BreadcrumbRow>
+        <HeaderWrapper>
+          <Title icon={pr_icon} iconWidth="25px">
+            {prDeployment.gh_pr_name}
+          </Title>
+          <InfoWrapper>
+            {prDeployment.subdomain && (
+              <PRLink to={prDeployment.subdomain} target="_blank">
+                <i className="material-icons">link</i>
+                {prDeployment.subdomain}
+              </PRLink>
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+            </TagWrapper>
+          </InfoWrapper>
+          <Flex>
+            <Status>
+              <StatusDot status={prDeployment.status} />
+              {capitalize(prDeployment.status)}
+            </Status>
+            <Dot>•</Dot>
+            <DeploymentImageContainer>
+              <DeploymentTypeIcon src={integrationList.repo.icon} />
+              <RepositoryName
+                onMouseOver={() => {
+                  setShowRepoTooltip(true);
+                }}
+                onMouseOut={() => {
+                  setShowRepoTooltip(false);
+                }}
+              >
+                {repository}
+              </RepositoryName>
+              {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+            </DeploymentImageContainer>
+            <Dot>•</Dot>
+            <GHALink
+              to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
+              target="_blank"
+            >
+              <img src={github} /> GitHub PR
               <i className="material-icons">open_in_new</i>
               <i className="material-icons">open_in_new</i>
             </GHALink>
             </GHALink>
-          ) : null}
-        </Flex>
-        <LinkToActionsWrapper></LinkToActionsWrapper>
-      </HeaderWrapper>
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={context.currentCluster}
-          currentView="cluster-dashboard"
-          sortType="Newest"
-          namespace={params.namespace}
-          disableBottomPadding
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </StyledExpandedChart>
+            {prDeployment.last_workflow_run_url ? (
+              <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
+                <span className="material-icons-outlined">
+                  play_circle_outline
+                </span>
+                Last workflow run
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            ) : null}
+          </Flex>
+          <LinkToActionsWrapper></LinkToActionsWrapper>
+        </HeaderWrapper>
+        {porterYAMLErrors.length > 0 ? (
+          <Banner type="error">
+            Your porter.yaml file has errors. Please fix them before deploying.
+            <LinkButton
+              onClick={() => {
+                setExpandedPorterYAMLErrors(porterYAMLErrors);
+              }}
+            >
+              View details
+            </LinkButton>
+          </Banner>
+        ) : null}
+        <ChartListWrapper>
+          <ChartList
+            currentCluster={context.currentCluster}
+            currentView="cluster-dashboard"
+            sortType="Newest"
+            namespace={params.namespace}
+            disableBottomPadding
+            closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+          />
+        </ChartListWrapper>
+      </StyledExpandedChart>
+    </>
   );
   );
 };
 };
 
 
@@ -150,6 +213,22 @@ const ArrowIcon = styled.img`
   opacity: 50%;
   opacity: 50%;
 `;
 `;
 
 
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
 const BreadcrumbRow = styled.div`
 const BreadcrumbRow = styled.div`
   width: 100%;
   width: 100%;
   display: flex;
   display: flex;

+ 431 - 129
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -2,46 +2,90 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
-import Selector from "components/Selector";
-
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-
 import _ from "lodash";
 import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
 import DeploymentCard from "./DeploymentCard";
-import { Environment, PRDeployment, PullRequest } from "../types";
+import { PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
 import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
 import { deployments, pull_requests } from "../mocks";
-import PullRequestCard from "./PullRequestCard";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
-import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
-import SearchBar from "components/SearchBar";
-import CheckboxRow from "components/form-components/CheckboxRow";
-import DocsHelper from "components/DocsHelper";
-import EnvironmentSettings from "../environments/EnvironmentSettings";
-
-const AvailableStatusFilters = [
-  "all",
-  "created",
-  "failed",
-  "active",
-  "inactive",
-  "not_deployed",
-];
+import DashboardHeader from "../../DashboardHeader";
+import RadioFilter from "components/RadioFilter";
+import Placeholder from "components/Placeholder";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+
+import pullRequestIcon from "assets/pull_request_icon.svg";
+import filterOutline from "assets/filter-outline.svg";
+import sort from "assets/sort.svg";
+import { search } from "shared/search";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+
+const AvailableStatusFilters = ["all", "created", "failed", "not_deployed"];
 
 
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 
 
+const HARD_CODED_DEPLOYMENTS: PRDeployment[] = [
+  {
+    id: 1,
+    created_at: "2021-03-01T00:00:00.000Z",
+    updated_at: "2021-03-01T00:00:00.000Z",
+    subdomain: "subdomain",
+    status: "created",
+    environment_id: 1,
+    pull_request_id: 1,
+    namespace: "namespace",
+    last_workflow_run_url: "",
+    gh_installation_id: 1,
+    gh_deployment_id: 1,
+    gh_pr_name: "gh_pr_name",
+    gh_repo_owner: "meehawk",
+    gh_repo_name: "meehawk",
+    gh_commit_sha: "3659ef050a687da4d04bb870b27058bd9d1957be",
+    gh_pr_branch_from: "gh_pr_branch_from",
+    gh_pr_branch_into: "gh_pr_branch_into",
+  },
+  {
+    id: 2,
+    created_at: "2021-03-01T00:00:00.000Z",
+    updated_at: "2021-03-01T00:00:00.000Z",
+    subdomain: "subdomain",
+    status: "created",
+    environment_id: 1,
+    pull_request_id: 1,
+    namespace: "namespace",
+    last_workflow_run_url: "",
+    gh_installation_id: 1,
+    gh_deployment_id: 1,
+    gh_pr_name: "some_awesome_pr",
+    gh_repo_owner: "godzilla",
+    gh_repo_name: "kong",
+    gh_commit_sha: "3659ef050a687da4d04bb870b27058bd9d1957be",
+    gh_pr_branch_from: "gh_pr_branch_from",
+    gh_pr_branch_into: "gh_pr_branch_into",
+  },
+];
+
 const DeploymentList = () => {
 const DeploymentList = () => {
+  const [sortOrder, setSortOrder] = useState("Newest");
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
   const [hasError, setHasError] = useState(false);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
+  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>(
+    HARD_CODED_DEPLOYMENTS
+  );
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [searchValue, setSearchValue] = useState("");
+  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
 
   const [
   const [
     statusSelectorVal,
     statusSelectorVal,
     setStatusSelectorVal,
     setStatusSelectorVal,
-  ] = useState<AvailableStatusFiltersType>("active");
+  ] = useState<AvailableStatusFiltersType>("all");
 
 
   const { currentProject, currentCluster } = useContext(Context);
   const { currentProject, currentCluster } = useContext(Context);
   const { getQueryParam, pushQueryParams } = useRouting();
   const { getQueryParam, pushQueryParams } = useRouting();
@@ -55,18 +99,16 @@ const DeploymentList = () => {
 
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
 
-  const getPRDeploymentList = () => {
-    return api.getPRDeploymentList(
+  const getEnvironment = () => {
+    return api.getEnvironment(
       "<token>",
       "<token>",
-      {
-        environment_id: Number(environment_id),
-      },
+      {},
       {
       {
         project_id: currentProject.id,
         project_id: currentProject.id,
         cluster_id: currentCluster.id,
         cluster_id: currentCluster.id,
+        environment_id: Number(environment_id),
       }
       }
     );
     );
-    // return mockRequest();
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
@@ -90,28 +132,72 @@ const DeploymentList = () => {
     let isSubscribed = true;
     let isSubscribed = true;
     setIsLoading(true);
     setIsLoading(true);
 
 
-    getPRDeploymentList().then(({ data }) => {
-      const deploymentList = data;
-
-      if (!isSubscribed) {
-        return;
-      }
+    Promise.allSettled([
+      validatePorterYAML({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getEnvironment(),
+    ])
+      .then(
+        ([
+          validatePorterYAMLResponse,
+          getDeploymentsResponse,
+          getEnvironmentResponse,
+        ]) => {
+          const deploymentList =
+            getDeploymentsResponse.status === "fulfilled"
+              ? getDeploymentsResponse.value.data
+              : {};
+          const environmentList =
+            getEnvironmentResponse.status === "fulfilled"
+              ? getEnvironmentResponse.value.data
+              : {};
+          const porterYAMLErrors =
+            validatePorterYAMLResponse.status === "fulfilled"
+              ? validatePorterYAMLResponse.value.data.errors
+              : [];
+
+          if (!isSubscribed) {
+            return;
+          }
+
+          setPorterYAMLErrors(porterYAMLErrors);
+          setDeploymentList(
+            deploymentList.deployments || HARD_CODED_DEPLOYMENTS
+          );
+          setPullRequests(deploymentList.pull_requests || []);
 
 
-      setDeploymentList(deploymentList.deployments || []);
-      setPullRequests(deploymentList.pull_requests || []);
+          setNewCommentsDisabled(
+            environmentList.new_comments_disabled || false
+          );
 
 
-      setIsLoading(false);
-    });
+          setIsLoading(false);
+        }
+      )
+      .catch(() => {
+        setDeploymentList(HARD_CODED_DEPLOYMENTS);
+      });
 
 
     return () => {
     return () => {
       isSubscribed = false;
       isSubscribed = false;
     };
     };
-  }, [currentCluster, currentProject]);
+  }, [currentCluster, currentProject, environment_id]);
 
 
   const handleRefresh = async () => {
   const handleRefresh = async () => {
     setIsLoading(true);
     setIsLoading(true);
     try {
     try {
-      const { data } = await getPRDeploymentList();
+      const { data } = await getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      });
       setDeploymentList(data.deployments || []);
       setDeploymentList(data.deployments || []);
       setPullRequests(data.pull_requests || []);
       setPullRequests(data.pull_requests || []);
     } catch (error) {
     } catch (error) {
@@ -141,29 +227,29 @@ const DeploymentList = () => {
   };
   };
 
 
   const filteredDeployments = useMemo(() => {
   const filteredDeployments = useMemo(() => {
-    // Only filter out inactive when status filter is "active"
-    if (statusSelectorVal === "active") {
-      return deploymentList
-        .filter((d) => {
-          return d.status !== "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+    const filteredByStatus = deploymentList.filter(
+      (d) => !["deleted", "inactive"].includes(d.status)
+    );
 
 
-    if (statusSelectorVal === "inactive") {
-      return deploymentList
-        .filter((d) => {
-          return d.status === "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+    const filteredBySearch = search<PRDeployment>(
+      filteredByStatus,
+      searchValue,
+      {
+        isCaseSensitive: false,
+        keys: ["gh_pr_name", "gh_repo_name", "gh_repo_owner"],
+      }
+    );
 
 
-    return deploymentList;
-  }, [statusSelectorVal, deploymentList, searchValue]);
+    switch (sortOrder) {
+      case "Newest":
+        return _.sortBy(filteredBySearch, "updated_at").reverse();
+      case "Oldest":
+        return _.sortBy(filteredBySearch, "updated_at");
+      case "Alphabetical":
+      default:
+        return _.sortBy(filteredBySearch, "gh_pr_name");
+    }
+  }, [statusSelectorVal, deploymentList, searchValue, sortOrder]);
 
 
   const filteredPullRequests = useMemo(() => {
   const filteredPullRequests = useMemo(() => {
     if (statusSelectorVal !== "inactive") {
     if (statusSelectorVal !== "inactive") {
@@ -178,24 +264,24 @@ const DeploymentList = () => {
   const renderDeploymentList = () => {
   const renderDeploymentList = () => {
     if (isLoading) {
     if (isLoading) {
       return (
       return (
-        <Placeholder>
+        <LoadingWrapper>
           <Loading />
           <Loading />
-        </Placeholder>
+        </LoadingWrapper>
       );
       );
     }
     }
 
 
-    if (!deploymentList.length && !pullRequests.length) {
+    if (!deploymentList.length) {
       return (
       return (
-        <Placeholder>
+        <Placeholder height="calc(100vh - 400px)">
           No preview apps have been found. Open a PR to create a new preview
           No preview apps have been found. Open a PR to create a new preview
           app.
           app.
         </Placeholder>
         </Placeholder>
       );
       );
     }
     }
 
 
-    if (!filteredDeployments.length && !filteredPullRequests.length) {
+    if (!filteredDeployments.length) {
       return (
       return (
-        <Placeholder>
+        <Placeholder height="calc(100vh - 400px)">
           No preview apps have been found with the given filter.
           No preview apps have been found with the given filter.
         </Placeholder>
         </Placeholder>
       );
       );
@@ -203,7 +289,8 @@ const DeploymentList = () => {
 
 
     return (
     return (
       <>
       <>
-        {filteredPullRequests.map((pr) => {
+        {/* Deprecated -> New Preview Env button */}
+        {/* {filteredPullRequests.map((pr) => {
           return (
           return (
             <PullRequestCard
             <PullRequestCard
               key={pr.pr_title}
               key={pr.pr_title}
@@ -211,8 +298,8 @@ const DeploymentList = () => {
               onCreation={handlePreviewEnvironmentManualCreation}
               onCreation={handlePreviewEnvironmentManualCreation}
             />
             />
           );
           );
-        })}
-        {filteredDeployments.map((d) => {
+        })} */}
+        {filteredDeployments.map((d: any) => {
           return (
           return (
             <DeploymentCard
             <DeploymentCard
               key={d.id}
               key={d.id}
@@ -227,25 +314,87 @@ const DeploymentList = () => {
     );
     );
   };
   };
 
 
-  const handleStatusFilterChange = (value: string) => {
-    pushQueryParams({ status_filter: value });
-    setStatusSelectorVal(value);
+  const handleToggleCommentStatus = (currentlyDisabled: boolean) => {
+    api
+      .toggleNewCommentForEnvironment(
+        "<token>",
+        {
+          disable: !currentlyDisabled,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: Number(environment_id),
+        }
+      )
+      .then(() => {
+        setNewCommentsDisabled(!currentlyDisabled);
+      });
   };
   };
 
 
+  useEffect(() => {
+    pushQueryParams({ status_filter: statusSelectorVal });
+  }, [statusSelectorVal]);
+
   return (
   return (
     <>
     <>
-      <PreviewEnvironmentsHeader />
-      <Flex>
-        <BackButton to={"/preview-environments"} className="material-icons">
-          keyboard_backspace
-        </BackButton>
-
-        <Icon
-          src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
-          alt="git repository icon"
-        />
-        <Title>{selectedRepo}</Title>
-
+      {expandedPorterYAMLErrors.length && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors([])}
+          height="auto"
+        >
+          <Message>
+            {expandedPorterYAMLErrors.map((el) => {
+              return (
+                <div>
+                  {"- "}
+                  {el}
+                </div>
+              );
+            })}
+          </Message>
+        </Modal>
+      )}
+      <BreadcrumbRow>
+        <Breadcrumb to="/preview-environments">
+          <ArrowIcon src={pullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+        title={
+          <Flex>
+            <StyledLink
+              to={`https://github.com/${selectedRepo}`}
+              target="_blank"
+            >
+              {selectedRepo}
+            </StyledLink>
+            <DynamicLink
+              to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/settings`}
+            >
+              <I className="material-icons">more_vert</I>
+            </DynamicLink>
+          </Flex>
+        }
+        description={`Preview environments for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      {porterYAMLErrors.length > 0 ? (
+        <Banner type="error">
+          Your porter.yaml file has errors. Please fix them before deploying.
+          <LinkButton
+            onClick={() => {
+              setExpandedPorterYAMLErrors(porterYAMLErrors);
+            }}
+          >
+            View details
+          </LinkButton>
+        </Banner>
+      ) : null}
+      {/* <Flex>
         <ActionsWrapper>
         <ActionsWrapper>
           <StyledStatusSelector>
           <StyledStatusSelector>
             <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
             <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
@@ -282,8 +431,54 @@ const DeploymentList = () => {
             <EnvironmentSettings environmentId={environment_id} />
             <EnvironmentSettings environmentId={environment_id} />
           </StyledStatusSelector>
           </StyledStatusSelector>
         </ActionsWrapper>
         </ActionsWrapper>
-      </Flex>
-
+      </Flex> */}
+      <FlexRow>
+        <Flex>
+          <SearchRowWrapper>
+            <SearchBarWrapper>
+              <i className="material-icons">search</i>
+              <SearchInput
+                value={searchValue}
+                onChange={(e: any) => {
+                  setSearchValue(e.target.value);
+                }}
+                placeholder="Search"
+              />
+            </SearchBarWrapper>
+          </SearchRowWrapper>
+          <RadioFilter
+            icon={filterOutline}
+            selected={statusSelectorVal}
+            setSelected={setStatusSelectorVal}
+            options={AvailableStatusFilters.map((filter) => ({
+              value: filter,
+              label: _.startCase(filter),
+            }))}
+            name="Status"
+          />
+        </Flex>
+        <Flex>
+          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+            <i className="material-icons">refresh</i>
+          </RefreshButton>
+          <RadioFilter
+            icon={sort}
+            selected={sortOrder}
+            setSelected={setSortOrder}
+            options={[
+              { label: "Newest", value: "Newest" },
+              { label: "Oldest", value: "Oldest" },
+              { label: "Alphabetical", value: "Alphabetical" },
+            ]}
+            name="Sort"
+          />
+          <CreatePreviewEnvironmentButton
+            to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
+          >
+            <i className="material-icons">add</i> New preview deployment
+          </CreatePreviewEnvironmentButton>
+        </Flex>
+      </FlexRow>
       <Container>
       <Container>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
       </Container>
       </Container>
@@ -304,6 +499,85 @@ const mockRequest = () =>
     );
     );
   });
   });
 
 
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  margin-bottom: -3px;
+  cursor: pointer;
+  width: 30px;
+  border-radius: 40px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
+`;
+
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
+`;
+
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const Flex = styled.div`
 const Flex = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -333,7 +607,6 @@ const Icon = styled.img`
   width: 25px;
   width: 25px;
   height: 25px;
   height: 25px;
   margin-right: 6px;
   margin-right: 6px;
-  margin-left: 14px;
 `;
 `;
 
 
 const Title = styled.div`
 const Title = styled.div`
@@ -345,11 +618,6 @@ const Title = styled.div`
   color: #ffffff;
   color: #ffffff;
 `;
 `;
 
 
-const ActionsWrapper = styled.div`
-  display: flex;
-  margin-left: auto;
-`;
-
 const RefreshButton = styled.button`
 const RefreshButton = styled.button`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -372,26 +640,6 @@ const RefreshButton = styled.button`
   }
   }
 `;
 `;
 
 
-const Placeholder = styled.div`
-  padding: 30px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 40vh;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
 const Container = styled.div`
 const Container = styled.div`
   margin-top: 33px;
   margin-top: 33px;
   padding-bottom: 120px;
   padding-bottom: 120px;
@@ -419,30 +667,84 @@ const SearchInput = styled.input`
   background: none;
   background: none;
   width: 100%;
   width: 100%;
   color: white;
   color: white;
-  padding: 0;
-  height: 20px;
+  height: 100%;
 `;
 `;
 
 
 const SearchRow = styled.div`
 const SearchRow = styled.div`
   display: flex;
   display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
   align-items: center;
   align-items: center;
-  padding: 10px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  max-height: 35px;
-  background: #ffffff11;
-  margin-right: 15px;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const CreatePreviewEnvironmentButton = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  min-width: 155px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
 
 
-  i {
+  > i {
+    color: white;
     width: 18px;
     width: 18px;
     height: 18px;
     height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 20px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
   }
   }
 `;
 `;

+ 414 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -0,0 +1,414 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { CellProps } from "react-table";
+import { Context } from "shared/Context";
+import { useParams } from "react-router";
+import { PRDeployment } from "../types";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import Helper from "components/form-components/Helper";
+import Table from "components/Table";
+import pr_icon from "assets/pull_request_icon.svg";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
+
+const dummyData: any = [
+  {
+    name: "this is a name",
+    branches: "asdf",
+  },
+  {
+    name: "this is a name",
+    branches: "asdf",
+  },
+  {
+    name: "this is a name",
+    branches: "asdf",
+  },
+];
+
+const CreateEnvironment: React.FC = () => {
+  const { environment_id, repo_name, repo_owner } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const selectedRepo = `${repo_owner}/${repo_name}`;
+
+  const columns = React.useMemo(
+    () => [
+      {
+        Header: "Monitors",
+        columns: [
+          {
+            Header: "Open pull requests",
+            accessor: "name",
+            width: 140,
+            Cell: ({ row }: CellProps<any>) => {
+              return (
+                <div style={{
+                  cursor: 'pointer',
+                }} onClick={() => alert("Hello world")}>
+                  <PRName>
+                    <PRIcon src={pr_icon} alt="pull request icon" />
+                    <EllipsisTextWrapper tooltipText="test">
+                      "test"
+                    </EllipsisTextWrapper>
+                    <Spacer />
+                    <RepoLink to="" target="_blank">
+                      <i className="material-icons">open_in_new</i>
+                      View last workflow
+                    </RepoLink>
+                  </PRName>
+
+                  <Flex>
+                    <DeploymentImageContainer>
+                      <InfoWrapper>
+                        <LastDeployed>Last updated xyz</LastDeployed>
+                      </InfoWrapper>
+                      <SepDot>•</SepDot>
+                      <MergeInfoWrapper>
+                        <MergeInfo>
+                          from-this-branch
+                          <i className="material-icons">arrow_forward</i>
+                          to-this-branch
+                        </MergeInfo>
+                      </MergeInfoWrapper>
+                    </DeploymentImageContainer>
+                  </Flex>
+                </div>
+              );
+            },
+          },
+        ],
+      },
+    ],
+    []
+  );
+
+  return (
+    <>
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environment_id}/${selectedRepo}`}
+        >
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        title="Create a preview deployment"
+        disableLineBreak
+        capitalize={false}
+      />
+      <DarkMatter />
+      <Helper>
+        Select an open pull request to preview. Pull requests must contain a{" "}
+        <Code>porter.yaml</Code> file.
+      </Helper>
+      <Br height="10px" />
+      <Table
+        columns={columns}
+        data={dummyData}
+        placeholder="No open pull requests found."
+      />
+      <SubmitButton>Create preview deployment</SubmitButton>
+    </>
+  );
+};
+
+export default CreateEnvironment;
+
+const Code = styled.span`
+  font-family: monospace; ;
+`;
+
+const Spacer = styled.div`
+  width: 5px;
+`;
+
+const SepDot = styled.div`
+  color: #aaaabb66;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+  margin-left: 7px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  margin-top: 30px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -15px;
+`;
+
+const DeleteButton = styled.div`
+  height: 30px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  width: 210px;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 20px;
+  text-align: left;
+  border-radius: 5px;
+  cursor: pointer;
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: brightness(120%);
+  }
+  background: #b91133;
+  border: none;
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+const Br = styled.div<{ height: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "2px"};
+`;
+
+const StyledPlaceholder = styled.div`
+  width: 100%;
+  padding: 30px;
+  font-size: 13px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -10px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const EnvironmentsGrid = styled.div`
+  padding-bottom: 150px;
+  display: grid;
+  grid-row-gap: 15px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin: 35px 0 30px;
+  padding-left: 0px;
+`;
+
+const Button = styled(DynamicLink)`
+  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: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 0 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -2,11 +2,8 @@ import React, { useContext, useState } from "react";
 import { capitalize } from "shared/string_utils";
 import { capitalize } from "shared/string_utils";
 import styled from "styled-components";
 import styled from "styled-components";
 import { Environment } from "../types";
 import { Environment } from "../types";
-import Options from "components/OptionsDropdown";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import Modal from "main/home/modals/Modal";
-import InputRow from "components/form-components/InputRow";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 import { RepoLink } from "../components/styled";
 import { RepoLink } from "../components/styled";
 
 

+ 266 - 188
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -1,241 +1,319 @@
-import DocsHelper from "components/DocsHelper";
-import CheckboxRow from "components/form-components/CheckboxRow";
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useParams } from "react-router";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
-import Loading from "components/Loading";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import { Environment, EnvironmentDeploymentMode } from "../types";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
-import Modal from "main/home/modals/Modal";
-import React, { useContext, useReducer, useState } from "react";
-import api from "shared/api";
+import _ from "lodash";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import styled, { css, keyframes } from "styled-components";
-import BranchFilterSelector from "../components/BranchFilterSelector";
-import { Environment } from "../types";
+import PageNotFound from "components/PageNotFound";
 
 
-const EnvironmentSettings = ({ environmentId }: { environmentId: string }) => {
-  const { currentCluster, currentProject, setCurrentError } = useContext(
+/**
+ * 
+ * TODO Soham:
+ * 
+ * - Handle errors when fetching environments
+ * - Handle errors when the environment is not found
+ * - Handle errors on saving and deleting the environment
+ */
+const EnvironmentSettings: React.FC = () => {
+  const [error, setError] = useState("");
+  const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
     Context
   );
   );
-
-  const [show, toggle] = useReducer((prev) => !prev, false);
-
   const [environment, setEnvironment] = useState<Environment>();
   const [environment, setEnvironment] = useState<Environment>();
-
-  const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
-
-  const [deploymentMode, setDeploymentMode] = useState<Environment["mode"]>(
-    "auto"
-  );
-  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
-  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
-
   const [saveStatus, setSaveStatus] = useState("");
   const [saveStatus, setSaveStatus] = useState("");
+  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [
+    deploymentMode,
+    setDeploymentMode,
+  ] = useState<EnvironmentDeploymentMode>("manual");
+  const {
+    environment_id: environmentId,
+    repo_name: repoName,
+    repo_owner: repoOwner,
+  } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
 
 
-  const [isLoading, setIsLoading] = useState(false);
-
-  const getEnvironment = async () => {
-    return api.getEnvironment<Environment>(
-      "<token>",
-      {},
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-        environment_id: Number(environmentId),
-      }
-    );
-  };
-
-  const getBranches = async (env: Environment) => {
-    return api.getBranches<string[]>(
-      "<token>",
-      {},
-      {
-        project_id: env.project_id,
-        git_repo_id: env.git_installation_id,
-        kind: "github",
-        owner: env.git_repo_owner,
-        name: env.git_repo_name,
-      }
-    );
-  };
+  const selectedRepo = `${repoOwner}/${repoName}`;
 
 
-  const handleToggleCommentStatus = async (currentlyDisabled: boolean) => {
-    setIsNewCommentsDisabled(!currentlyDisabled);
-  };
-
-  const handleOpen = async () => {
-    setIsLoading(true);
-
-    try {
-      const environment = await getEnvironment().then((res) => res.data);
-      const branches = await getBranches(environment).then((res) => res.data);
+  useEffect(() => {
+    const getPreviewEnvironmentSettings = async () => {
+      const { data: environment } = await api.getEnvironment<Environment>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: parseInt(environmentId),
+        }
+      );
 
 
       setEnvironment(environment);
       setEnvironment(environment);
-      setIsNewCommentsDisabled(environment.disable_new_comments);
+      setNewCommentsDisabled(environment.disable_new_comments);
       setDeploymentMode(environment.mode);
       setDeploymentMode(environment.mode);
-      setSelectedBranches(environment.git_repo_branches.filter(Boolean));
-
-      setAvailableBranches(branches);
-    } catch (error) {
-      console.error(error);
-    } finally {
-      setIsLoading(false);
-      toggle();
+    };
+
+    try {
+      getPreviewEnvironmentSettings();
+    } catch (err) {
+      setCurrentError(err);
     }
     }
-  };
+  }, []);
 
 
-  const handleSave = () => {
+  const handleSave = async () => {
     setSaveStatus("loading");
     setSaveStatus("loading");
 
 
-    api
-      .updateEnvironment(
+    try {
+      await api.updateEnvironment(
         "<token>",
         "<token>",
         {
         {
           mode: deploymentMode,
           mode: deploymentMode,
-          disable_new_comments: isNewCommentsDisabled,
-          git_repo_branches: selectedBranches,
+          disable_new_comments: newCommentsDisabled,
+          git_repo_branches: [],
         },
         },
         {
         {
           project_id: currentProject.id,
           project_id: currentProject.id,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
           environment_id: Number(environmentId),
           environment_id: Number(environmentId),
         }
         }
-      )
-      .then(() => {
-        setSaveStatus("successful");
-        setTimeout(() => {
-          setSaveStatus(""), toggle();
-        }, 2000);
-        toggle();
-      })
-      .catch((error) => {
-        setCurrentError(error);
-        setSaveStatus("Couldn't update the environment, please try again.");
-      })
-      .finally(() => {
-        setSaveStatus("");
-      });
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+
+    setSaveStatus("");
   };
   };
 
 
   return (
   return (
     <>
     <>
-      <SettingsButton type="button" onClick={handleOpen} isLoading={isLoading}>
-        <i className="material-icons">settings</i>
-      </SettingsButton>
-      {show && (
-        <Modal
-          height="fit-content"
-          onRequestClose={toggle}
-          title={`Settings for ${environment.git_repo_owner}/${environment.git_repo_name}`}
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environmentId}/${selectedRepo}`}
         >
         >
-          <>
-            {/* Add branch selector (probably will have to create a new component that lets the user pick multiple) */}
-            <Heading>Allowed Branches</Heading>
-            <Helper>
-              If the pull request has a base branch included in this list, it
-              will be allowed to be deployed.
-              <br />
-              (Leave empty to allow all branches)
-            </Helper>
-            <BranchFilterSelector
-              value={selectedBranches}
-              onChange={setSelectedBranches}
-              options={availableBranches}
-            />
-
-            <Heading>Automatic pull request deployments</Heading>
-            <Helper>
-              If you enable this option, the new pull requests will be
-              automatically deployed.
-            </Helper>
-            {/* Add checkbox to change deployment mode (auto | manaul) */}
-            <CheckboxWrapper>
-              <CheckboxRow
-                label="Enable automatic deploys"
-                checked={deploymentMode === "auto"}
-                toggle={() =>
-                  setDeploymentMode((prev) =>
-                    prev === "auto" ? "manual" : "auto"
-                  )
-                }
-                wrapperStyles={{
-                  disableMargin: true,
-                }}
-              />
-              <DocsHelper
-                disableMargin
-                tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
-              />
-            </CheckboxWrapper>
-
-            <Heading>Disable new comments for new deployments</Heading>
-            <Helper>
-              When enabled new comments will not be created for new deployments.
-              Instead the last comment will be updated.
-            </Helper>
-            <CheckboxWrapper>
-              <CheckboxRow
-                label="Disable new comments for deployments"
-                checked={isNewCommentsDisabled}
-                toggle={() => handleToggleCommentStatus(isNewCommentsDisabled)}
-                wrapperStyles={{
-                  disableMargin: true,
-                }}
-              />
-              <DocsHelper
-                disableMargin
-                tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
-                placement="top-end"
-              />
-            </CheckboxWrapper>
-            <SubmitButton
-              onClick={handleSave}
-              clearPosition
-              text="Save"
-              statusPosition="right"
-              status={saveStatus}
-            />
-          </>
-        </Modal>
-      )}
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview environment settings"
+        description={`Preview environment settings for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      <StyledPlaceholder>
+        <Heading isAtTop>Pull request comment settings</Heading>
+        <Helper>
+          Update the most recent PR comment on every deploy. If disabled, a new
+          PR comment is made per deploy.
+        </Helper>
+        <CheckboxRow
+          label="Update the most recent PR comment"
+          checked={!newCommentsDisabled}
+          toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
+        />
+        <Br />
+        <Heading>Automatic preview deployments</Heading>
+        <Helper>
+          When enabled, preview deployments are automatically created for all
+          new pull requests.
+        </Helper>
+        <CheckboxRow
+          label="Automatically create preview deployments"
+          checked={deploymentMode === "auto"}
+          toggle={() =>
+            setDeploymentMode((deploymentMode) =>
+              deploymentMode === "auto" ? "manual" : "auto"
+            )
+          }
+        />
+        <SavePreviewEnvironmentSettings
+          text={"Save"}
+          status={saveStatus}
+          clearPosition={true}
+          statusPosition={"right"}
+          onClick={handleSave}
+        />
+        <Br />
+        <Heading>Delete preview environment</Heading>
+        <Helper>
+          Delete the Porter preview environment integration for this repo. All
+          preview deployments will also be destroyed.
+        </Helper>
+        <DeleteButton disabled={saveStatus === "loading"} onClick={_.noop}>
+          Delete preview environment
+        </DeleteButton>
+      </StyledPlaceholder>
     </>
     </>
   );
   );
 };
 };
 
 
 export default EnvironmentSettings;
 export default EnvironmentSettings;
 
 
-const rotatingAnimation = keyframes`
-  0% {
-    transform: rotate(0deg);
+const SavePreviewEnvironmentSettings = styled(SaveButton)`
+  margin-top: 30px;
+`;
+
+const DeleteButton = styled.button`
+  height: 30px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  width: 210px;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 20px;
+  text-align: left;
+  border-radius: 5px;
+  cursor: pointer;
+  user-select: none;
+  :focus {
+    outline: 0;
   }
   }
-  100% {
-    transform: rotate(360deg);
+  :hover {
+    filter: brightness(120%);
+  }
+  background: #b91133;
+  border: none;
+  :hover {
+    filter: brightness(120%);
   }
   }
 `;
 `;
 
 
-const iconAnimation = css`
-  animation: ${rotatingAnimation} 1s linear infinite;
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
 `;
 `;
 
 
-const SettingsButton = styled.button<{ isLoading: boolean }>`
-  background: none;
-  color: white;
-  border: none;
-  margin-left: 10px;
+const StyledPlaceholder = styled.div`
+  width: 100%;
+  padding: 30px;
+  font-size: 13px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -10px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
   cursor: pointer;
   cursor: pointer;
-  > i {
-    font-size: 20px;
-    ${({ isLoading }) => (isLoading ? iconAnimation : "")}
+  :hover {
+    background: #ffffff11;
   }
   }
 `;
 `;
 
 
-const CheckboxWrapper = styled.div`
+const Relative = styled.div`
+  position: relative;
+`;
+
+const EnvironmentsGrid = styled.div`
+  padding-bottom: 150px;
+  display: grid;
+  grid-row-gap: 15px;
+`;
+
+const ControlRow = styled.div`
   display: flex;
   display: flex;
+  margin-left: auto;
+  justify-content: space-between;
   align-items: center;
   align-items: center;
-  margin-top: 20px;
+  margin: 35px 0 30px;
+  padding-left: 0px;
 `;
 `;
 
 
-const SubmitButton = styled(SaveButton)`
-  margin-top: 20px;
-  align-items: flex-end;
+const Button = styled(DynamicLink)`
+  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: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
 `;
 `;

+ 70 - 54
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -8,13 +8,47 @@ import ButtonEnablePREnvironments from "../components/ButtonEnablePREnvironments
 import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { Environment } from "../types";
 import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
 import EnvironmentCard from "./EnvironmentCard";
+import Placeholder from "components/Placeholder";
+
+const HARD_CODED_ENVS: Environment[] = [
+  {
+    id: 12,
+    project_id: 1234,
+    cluster_id: 4321,
+    git_installation_id: 55,
+    name: "asdf",
+    git_repo_owner: "owned",
+    git_repo_name: "this-is-a-repo",
+    last_deployment_status: "failed",
+    deployment_count: 12,
+    mode: "manual",
+    git_repo_branches: [],
+    disable_new_comments: true,
+  },
+  {
+    id: 13,
+    project_id: 1234,
+    cluster_id: 4321,
+    git_installation_id: 55,
+    name: "asdf",
+    git_repo_owner: "owned",
+    git_repo_name: "this-is-a-repo",
+    last_deployment_status: "failed",
+    deployment_count: 12,
+    mode: "manual",
+    git_repo_branches: [],
+    disable_new_comments: true,
+  },
+];
 
 
 const EnvironmentsList = () => {
 const EnvironmentsList = () => {
   const { currentCluster, currentProject } = useContext(Context);
   const { currentCluster, currentProject } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [buttonIsReady, setButtonIsReady] = useState(false);
   const [buttonIsReady, setButtonIsReady] = useState(false);
 
 
-  const [environments, setEnvironments] = useState<Environment[]>([]);
+  const [environments, setEnvironments] = useState<Environment[]>(
+    HARD_CODED_ENVS
+  );
 
 
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
     setEnvironments((prev) => {
     setEnvironments((prev) => {
@@ -55,8 +89,12 @@ const EnvironmentsList = () => {
       }
       }
 
 
       setEnvironments(envs);
       setEnvironments(envs);
+
+      //
+      setEnvironments(HARD_CODED_ENVS);
     } catch (error) {
     } catch (error) {
-      setEnvironments([]);
+      // ret2: remove placeholder (set to empty array)
+      setEnvironments(HARD_CODED_ENVS);
     }
     }
   };
   };
 
 
@@ -80,29 +118,34 @@ const EnvironmentsList = () => {
     <>
     <>
       <PreviewEnvironmentsHeader />
       <PreviewEnvironmentsHeader />
       <Relative>
       <Relative>
-        {isLoading || !buttonIsReady ? (
-          <FloatingPlaceholder>
-            <Loading />
-          </FloatingPlaceholder>
-        ) : null}
         <ControlRow>
         <ControlRow>
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
         </ControlRow>
         </ControlRow>
-
-        {environments.length === 0 ? (
-          <Placeholder>
-            No repositories found with Preview Environments enabled.
-          </Placeholder>
+        {isLoading ? (
+          <LoadingWrapper>
+            <Loading />
+          </LoadingWrapper>
         ) : (
         ) : (
-          <EnvironmentsGrid>
-            {environments.map((env) => (
-              <EnvironmentCard
-                key={env.id}
-                environment={env}
-                onDelete={removeEnvironmentFromList}
-              />
-            ))}
-          </EnvironmentsGrid>
+          <>
+            {environments.length === 0 ? (
+              <Placeholder
+                title="No repositories found"
+                height="calc(100vh - 400px)"
+              >
+                No repositories were found with Preview Environments enabled.
+              </Placeholder>
+            ) : (
+              <EnvironmentsGrid>
+                {environments.map((env) => (
+                  <EnvironmentCard
+                    key={env.id}
+                    environment={env}
+                    onDelete={removeEnvironmentFromList}
+                  />
+                ))}
+              </EnvironmentsGrid>
+            )}
+          </>
         )}
         )}
       </Relative>
       </Relative>
     </>
     </>
@@ -111,45 +154,18 @@ const EnvironmentsList = () => {
 
 
 export default EnvironmentsList;
 export default EnvironmentsList;
 
 
-const Relative = styled.div`
-  position: relative;
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
 `;
 `;
 
 
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const FloatingPlaceholder = styled(Placeholder)`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  margin-top: 0px;
-  z-index: 999;
+const Relative = styled.div`
+  position: relative;
 `;
 `;
 
 
 const EnvironmentsGrid = styled.div`
 const EnvironmentsGrid = styled.div`
-  margin-top: 32px;
   padding-bottom: 150px;
   padding-bottom: 150px;
   display: grid;
   display: grid;
-  grid-row-gap: 25px;
+  grid-row-gap: 15px;
 `;
 `;
 
 
 const ControlRow = styled.div`
 const ControlRow = styled.div`
@@ -157,7 +173,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   margin-left: auto;
   justify-content: space-between;
   justify-content: space-between;
   align-items: center;
   align-items: center;
-  margin: 35px 0;
+  margin: 35px 0 30px;
   padding-left: 0px;
   padding-left: 0px;
 `;
 `;
 
 

+ 12 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -5,6 +5,8 @@ import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
 import DeploymentList from "./deployments/DeploymentList";
 import EnvironmentsList from "./environments/EnvironmentsList";
 import EnvironmentsList from "./environments/EnvironmentsList";
+import EnvironmentSettings from "./environments/EnvironmentSettings";
+import DeployEnvironment from "./environments/CreateEnvironment";
 
 
 export const Routes = () => {
 export const Routes = () => {
   const { path } = useRouteMatch();
   const { path } = useRouteMatch();
@@ -23,6 +25,16 @@ export const Routes = () => {
         <Route path={`${path}/details/:namespace?`}>
         <Route path={`${path}/details/:namespace?`}>
           <DeploymentDetail />
           <DeploymentDetail />
         </Route>
         </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/settings`}
+        >
+          <EnvironmentSettings />
+        </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/create`}
+        >
+          <DeployEnvironment />
+        </Route>
         <Route
         <Route
           path={`${path}/deployments/:environment_id/:repo_owner/:repo_name`}
           path={`${path}/deployments/:environment_id/:repo_owner/:repo_name`}
         >
         >

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts

@@ -29,6 +29,8 @@ export type PRDeployment = {
   gh_pr_branch_into?: string;
   gh_pr_branch_into?: string;
 };
 };
 
 
+export type EnvironmentDeploymentMode = "manual" | "auto";
+
 export type Environment = {
 export type Environment = {
   id: number;
   id: number;
   project_id: number;
   project_id: number;
@@ -41,7 +43,7 @@ export type Environment = {
   disable_new_comments: boolean;
   disable_new_comments: boolean;
   last_deployment_status: DeploymentStatusUnion;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   deployment_count: number;
-  mode: "manual" | "auto";
+  mode: EnvironmentDeploymentMode;
 };
 };
 
 
 export type PullRequest = {
 export type PullRequest = {

+ 50 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts

@@ -0,0 +1,50 @@
+import api from "shared/api";
+
+interface ValidatePorterYAMLProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+  branch?: string;
+}
+
+export const validatePorterYAML = ({
+  projectID,
+  clusterID,
+  environmentID,
+  branch,
+}: ValidatePorterYAMLProps) => {
+  return api.validatePorterYAML(
+    "<token>",
+    {
+      ...(branch ? { branch } : {}),
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+      environment_id: environmentID,
+    }
+  );
+};
+
+interface GetPRDeploymentListProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+}
+
+export const getPRDeploymentList = ({
+  clusterID,
+  projectID,
+  environmentID,
+}: GetPRDeploymentListProps) => {
+  return api.getPRDeploymentList(
+    "<token>",
+    {
+      environment_id: environmentID,
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+    }
+  );
+};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 import React, { useContext, useState } from "react";
 import React, { useContext, useState } from "react";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -3,7 +3,7 @@ import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
 import semver from "semver";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { BackButton, Card } from "../../launch/components/styles";
 import { BackButton, Card } from "../../launch/components/styles";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 import { VersionSelector } from "../../launch/components/VersionSelector";
 import { VersionSelector } from "../../launch/components/VersionSelector";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import React, { createContext, useContext, useEffect, useState } from "react";
 import React, { createContext, useContext, useEffect, useState } from "react";
 import { useParams } from "react-router";
 import { useParams } from "react-router";
 import api from "shared/api";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -5,7 +5,7 @@ import { Card } from "../../launch/components/styles";
 import { Stack } from "../../types";
 import { Stack } from "../../types";
 import sliders from "assets/sliders.svg";
 import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import { useRouteMatch } from "react-router";
 import { useRouteMatch } from "react-router";
 
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -3,7 +3,7 @@ import Loading from "components/Loading";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import styled from "styled-components";
 import styled from "styled-components";
 import { Stack } from "./types";
 import { Stack } from "./types";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -156,7 +156,7 @@ const Overview = () => {
         Env Groups
         Env Groups
         {/* <InlineDocsHelper
         {/* <InlineDocsHelper
           disableMargin={true}
           disableMargin={true}
-          tooltipText="Environment Groups"
+          tooltipText="Environment groups"
           link="https://docs.porter.run/deploying-applications/environment-groups"
           link="https://docs.porter.run/deploying-applications/environment-groups"
         /> */}
         /> */}
       </Heading>
       </Heading>

+ 27 - 15
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -13,7 +13,6 @@ import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/porter-form/FormDebugger";
 import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
-import Banner from "components/Banner";
 
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -116,20 +115,24 @@ class Dashboard extends Component<PropsType, StateType> {
       );
       );
     } else if (this.currentTab() === "create-cluster") {
     } else if (this.currentTab() === "create-cluster") {
       let helperText = "Create a cluster to link to this project";
       let helperText = "Create a cluster to link to this project";
-      let helperType = "info";
+      let helperIcon = "info";
+      let helperColor = "white";
       if (
       if (
-        true
+        this.context.hasBillingEnabled &&
+        this.context.usage.current.clusters !== 0 &&
+        this.context.usage.current.clusters >= this.context.usage.limit.clusters
       ) {
       ) {
         helperText =
         helperText =
           "You need to update your billing to provision or connect a new cluster";
           "You need to update your billing to provision or connect a new cluster";
-        helperType = "warning";
+        helperIcon = "warning";
+        helperColor = "#f5cb42";
       }
       }
       return (
       return (
         <>
         <>
-          <Banner type={helperType} noMargin>
+          <Banner color={helperColor}>
+            <i className="material-icons">{helperIcon}</i>
             {helperText}
             {helperText}
           </Banner>
           </Banner>
-          <Br />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
         </>
       );
       );
@@ -176,7 +179,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                     </Overlay>
                   </DashboardIcon>
                   </DashboardIcon>
                   {currentProject && currentProject.name}
                   {currentProject && currentProject.name}
-                  {this.props.isAuthorized("settings", "", ["get", "list"]) && (
+                  {this.context.currentProject?.roles?.filter((obj: any) => {
+                    return obj.user_id === this.context.user.userId;
+                  })[0].kind === "admin" || (
                     <i
                     <i
                       className="material-icons"
                       className="material-icons"
                       onClick={onShowProjectSettings}
                       onClick={onShowProjectSettings}
@@ -223,18 +228,25 @@ const Br = styled.div`
   height: 1px;
   height: 1px;
 `;
 `;
 
 
-const Code = styled.div`
-  font-family: monospace;
-  margin: 0 7px;
-`;
-
 const DashboardWrapper = styled.div`
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
   padding-bottom: 100px;
 `;
 `;
 
 
-const A = styled.a`
-  margin-left: 10px;
-  color: #8590ff;
+const Banner = styled.div<{ color: string }>`
+  height: 40px;
+  width: 100%;
+  margin: 5px 0 30px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #ffffff11;
+  color: ${(props) => props.color};
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
 `;
 `;
 
 
 const TopRow = styled.div`
 const TopRow = styled.div`

+ 1 - 1
dashboard/src/main/home/infrastructure/ExpandedInfra.tsx

@@ -10,7 +10,7 @@ import DeployList from "./components/DeployList";
 import InfraResourceList from "./components/InfraResourceList";
 import InfraResourceList from "./components/InfraResourceList";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Header from "components/expanded-object/Header";
 import Header from "components/expanded-object/Header";
 import { Infrastructure, KindMap, Operation } from "shared/types";
 import { Infrastructure, KindMap, Operation } from "shared/types";
 import InfraSettings from "./components/InfraSettings";
 import InfraSettings from "./components/InfraSettings";

+ 2 - 2
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -7,7 +7,7 @@ import { pushFiltered } from "shared/routing";
 
 
 import { Column } from "react-table";
 import { Column } from "react-table";
 import styled from "styled-components";
 import styled from "styled-components";
-import Table from "components/Table";
+import Table from "components/OldTable";
 
 
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 
 
@@ -15,7 +15,7 @@ import _ from "lodash";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
 import { Infrastructure, KindMap } from "shared/types";
 import { Infrastructure, KindMap } from "shared/types";
 import { capitalize, readableDate } from "shared/string_utils";
 import { capitalize, readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
 import Description from "components/Description";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/DeployList.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
   OperationType,
 } from "shared/types";
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import ExpandedOperation from "./ExpandedOperation";
 import ExpandedOperation from "./ExpandedOperation";
 
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
   OperationType,
 } from "shared/types";
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx

@@ -4,7 +4,7 @@ import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import { TFState } from "shared/types";
 import { TFState } from "shared/types";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 
 type Props = {
 type Props = {
   infra_id: number;
   infra_id: number;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx

@@ -8,7 +8,7 @@ import Loading from "components/Loading";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 
 
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialsList from "./credentials/AWSCredentialList";
 import AWSCredentialsList from "./credentials/AWSCredentialList";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import GCPCredentialsList from "./credentials/GCPCredentialList";
 import GCPCredentialsList from "./credentials/GCPCredentialList";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx

@@ -9,7 +9,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import { Operation, OperationStatus, OperationType } from "shared/types";
 import { Operation, OperationStatus, OperationType } from "shared/types";
 import { readableDate } from "shared/string_utils";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 
 type Props = {
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;
   setCreatedCredential: (aws_integration_id: number) => void;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialForm from "./AWSCredentialForm";
 import AWSCredentialForm from "./AWSCredentialForm";
 import CredentialList from "./CredentialList";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 
 type Props = {
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;
   setCreatedCredential: (aws_integration_id: number) => void;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AzureCredentialForm from "./AzureCredentialForm";
 import AzureCredentialForm from "./AzureCredentialForm";
 import CredentialList from "./CredentialList";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/ClusterList.tsx

@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Description from "components/Description";
 import Description from "components/Description";
 import { ClusterType } from "shared/types";
 import { ClusterType } from "shared/types";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/DOCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import CredentialList from "./CredentialList";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";
 import Description from "components/Description";
 
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialForm.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
 import UploadArea from "components/form-components/UploadArea";
 import UploadArea from "components/form-components/UploadArea";
 
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import styled from "styled-components";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import GCPCredentialForm from "./GCPCredentialForm";
 import GCPCredentialForm from "./GCPCredentialForm";
 import CredentialList from "./CredentialList";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -20,7 +20,7 @@ import { provisionResourcesTracks } from "shared/anayltics";
 import DocsHelper from "components/DocsHelper";
 import DocsHelper from "components/DocsHelper";
 import Description from "components/Description";
 import Description from "components/Description";
 import api from "shared/api";
 import api from "shared/api";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import MultiSaveButton from "components/MultiSaveButton";
 import MultiSaveButton from "components/MultiSaveButton";
 import buildLogger from "shared/error_handling/logger";
 import buildLogger from "shared/error_handling/logger";

+ 1 - 1
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import RadioSelector from "components/RadioSelector";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";
 import TokenList from "./api-tokens/TokenList";

+ 1 - 1
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import RadioSelector from "components/RadioSelector";
 import { Role } from "./roles-admin/types";
 import { Role } from "./roles-admin/types";
 import SearchSelector from "components/SearchSelector";
 import SearchSelector from "components/SearchSelector";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx

@@ -1,6 +1,6 @@
 import Description from "components/Description";
 import Description from "components/Description";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import React, { useContext, useEffect, useState } from "react";
 import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 
 

+ 15 - 0
dashboard/src/shared/api.tsx

@@ -267,6 +267,20 @@ const toggleNewCommentForEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
 });
 });
 
 
+const validatePorterYAML = baseApi<
+  {
+    branch?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>("GET", (pathParams) => {
+  const { project_id, cluster_id, environment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/validate_porter_yaml`;
+});
+
 const createGCPIntegration = baseApi<
 const createGCPIntegration = baseApi<
   {
   {
     gcp_key_data: string;
     gcp_key_data: string;
@@ -2380,6 +2394,7 @@ export default {
   listEnvironments,
   listEnvironments,
   getEnvironment,
   getEnvironment,
   toggleNewCommentForEnvironment,
   toggleNewCommentForEnvironment,
+  validatePorterYAML,
   createGCPIntegration,
   createGCPIntegration,
   createInvite,
   createInvite,
   createNamespace,
   createNamespace,

+ 13 - 0
dashboard/src/shared/search.ts

@@ -0,0 +1,13 @@
+import Fuse from "fuse.js";
+
+export const search = <T>(
+  items: T[],
+  searchTerm: string,
+  options?: Fuse.IFuseOptions<T>
+) => {
+  if (!searchTerm) {
+    return items;
+  }
+  const fuse = new Fuse<T>(items, options);
+  return fuse.search(searchTerm).map((result) => result.item);
+};

+ 201 - 0
internal/integrations/preview/embed/deploy_driver.schema.json.unused

@@ -0,0 +1,201 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "title": "schema for the default deploy driver",
+  "type": "object",
+  "properties": {
+    "name": {
+      "type": "string",
+      "description": "resource name",
+      "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+      "maxLength": 50
+    },
+    "driver": {
+      "type": "string",
+      "description": "resource driver",
+      "enum": ["deploy", ""]
+    },
+    "depends_on": {
+      "type": "array",
+      "description": "list of resource names this resource depends on",
+      "minItems": 1,
+      "items": {
+        "type": "string",
+        "description": "dependency resource name",
+        "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+        "maxLength": 50
+      }
+    },
+    "source": {
+      "type": "object",
+      "description": "resource source",
+      "properties": {
+        "name": {
+          "type": "string",
+          "description": "source Helm chart name"
+        },
+        "version": {
+          "type": "string",
+          "description": "source Helm chart version"
+        },
+        "repo": {
+          "type": "string",
+          "description": "source Helm chart repo URL",
+          "default": "https://charts.getporter.dev"
+        }
+      },
+      "required": ["name"]
+    },
+    "target": {
+      "type": "object",
+      "description": "resource target",
+      "properties": {
+        "project": {
+          "type": "integer",
+          "description": "target Porter project ID"
+        },
+        "cluster": {
+          "type": "integer",
+          "description": "target Porter cluster ID"
+        },
+        "namespace": {
+          "type": "string",
+          "description": "target namespace"
+        }
+      }
+    },
+    "config": {
+      "type": "object",
+      "description": "resource configuration",
+      "additionalProperties": true
+    },
+    "if": {
+      "properties": {
+        "source": {
+          "properties": { "repo": { "const": "https://charts.getporter.dev" } }
+        }
+      }
+    },
+    "then": {
+      "properties": {
+        "config": {
+          "properties": {
+            "waitForJob": {
+              "type": "boolean",
+              "description": "wait for job to complete"
+            },
+            "onlyCreate": {
+              "type": "boolean",
+              "description": "only create the resource"
+            },
+            "build": {
+              "type": "object",
+              "description": "build configuration",
+              "properties": {
+                "use_cache": {
+                  "type": "boolean",
+                  "description": "use Porter build cache"
+                },
+                "method": {
+                  "type": "string",
+                  "description": "build method",
+                  "default": "docker",
+                  "enum": ["docker", "pack", "registry"]
+                },
+                "context": {
+                  "type": "string",
+                  "description": "build context"
+                },
+                "dockerfile": {
+                  "type": "string",
+                  "description": "Dockerfile path"
+                },
+                "image": {
+                  "type": "string",
+                  "description": "image name"
+                },
+                "builder": {
+                  "type": "string",
+                  "description": "buildpacks builder image"
+                },
+                "buildpacks": {
+                  "type": "array",
+                  "description": "list of buildpacks",
+                  "minItems": 1,
+                  "items": {
+                    "type": "string",
+                    "description": "buildpack"
+                  }
+                },
+                "env": {
+                  "type": "object",
+                  "description": "build-time environment variables",
+                  "additionalProperties": { "type": "string" }
+                }
+              },
+              "allOf": [
+                {
+                  "if": {
+                    "properties": {
+                      "method": { "const": "docker" }
+                    }
+                  },
+                  "then": {
+                    "dependentRequired": {
+                      "method": ["dockerfile"]
+                    }
+                  }
+                },
+                {
+                  "if": {
+                    "properties": {
+                      "method": { "const": "registry" }
+                    }
+                  },
+                  "then": {
+                    "dependentRequired": {
+                      "method": ["image"]
+                    }
+                  }
+                }
+              ]
+            },
+            "env_groups": {
+              "type": "array",
+              "description": "list of environment groups to use in the deployment",
+              "minItems": 1,
+              "items": {
+                "type": "object",
+                "description": "environment group",
+                "properties": {
+                  "name": {
+                    "type": "string",
+                    "description": "environment group name"
+                  },
+                  "version": {
+                    "type": "integer",
+                    "minimum": 0,
+                    "default": 0,
+                    "description": "environment group version"
+                  },
+                  "namespace": {
+                    "type": "string",
+                    "description": "environment group namespace"
+                  }
+                },
+                "required": ["name"]
+              }
+            },
+            "values": {
+              "type": "object",
+              "description": "Helm values to use for the deployment",
+              "additionalProperties": true
+            }
+          },
+          "required": ["build"]
+        }
+      },
+      "required": ["config"]
+    }
+  },
+  "required": ["name", "source"]
+}

+ 73 - 0
internal/integrations/preview/embed/job.values.schema.json

@@ -0,0 +1,73 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "schedule": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "value": {
+          "type": "string",
+          "default": "*/5 * * * *"
+        },
+        "successfulHistory": {
+          "type": "integer",
+          "default": 20
+        },
+        "failedHistory": {
+          "type": "integer",
+          "default": 20
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 90 - 0
internal/integrations/preview/embed/porteryaml.schema.json.unused

@@ -0,0 +1,90 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "version": {
+      "type": "string",
+      "description": "porter.yaml version",
+      "pattern": "^v[1-9][0-9]*$"
+    },
+    "resources": {
+      "type": "array",
+      "description": "list of resources",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "resource name",
+            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+            "maxLength": 50
+          },
+          "driver": {
+            "type": "string",
+            "description": "resource driver"
+          },
+          "depends_on": {
+            "type": "array",
+            "description": "list of resource names this resource depends on",
+            "minItems": 1,
+            "items": {
+              "type": "string",
+              "description": "dependency resource name",
+              "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+              "maxLength": 50
+            }
+          },
+          "source": {
+            "type": "object",
+            "description": "resource source",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "source Helm chart name"
+              },
+              "version": {
+                "type": "string",
+                "description": "source Helm chart version"
+              },
+              "repo": {
+                "type": "string",
+                "description": "source Helm chart repo URL"
+              }
+            }
+          },
+          "target": {
+            "type": "object",
+            "description": "resource target",
+            "properties": {
+              "project": {
+                "type": "integer",
+                "description": "target Porter project ID"
+              },
+              "cluster": {
+                "type": "integer",
+                "description": "target Porter cluster ID"
+              },
+              "namespace": {
+                "type": "string",
+                "description": "target namespace"
+              },
+              "app_name": {
+                "type": "string",
+                "description": "target app name",
+                "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                "maxLength": 50
+              }
+            }
+          },
+          "config": {
+            "type": "object",
+            "description": "resource config"
+          }
+        },
+        "required": ["name"]
+      }
+    }
+  },
+  "required": ["version", "resources"]
+}

+ 289 - 0
internal/integrations/preview/embed/web.values.schema.json

@@ -0,0 +1,289 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "ingress": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": true
+        },
+        "hosts": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "porter_hosts": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "provider": {
+          "type": "string"
+        },
+        "custom_domain": {
+          "type": "boolean",
+          "default": false
+        },
+        "custom_paths": {
+          "type": "string"
+        },
+        "rewriteCustomPathsEnabled": {
+          "type": "boolean",
+          "default": true
+        },
+        "annotations": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "wildcard": {
+          "type": "boolean",
+          "default": false
+        },
+        "tls": {
+          "type": "boolean",
+          "default": true
+        },
+        "useDefaultIngressTLSSecret": {
+          "type": "boolean",
+          "default": false
+        }
+      }
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "args": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    },
+    "autoscaling": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "minReplicas": {
+          "type": "integer",
+          "default": 1
+        },
+        "maxReplicas": {
+          "type": "integer",
+          "default": 10
+        },
+        "targetCPUUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        },
+        "targetMemoryUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        }
+      }
+    },
+    "health": {
+      "type": "object",
+      "properties": {
+        "livenessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/livez"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "initialDelaySeconds": {
+              "type": "integer",
+              "default": 0
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "successThreshold": {
+              "type": "integer",
+              "default": 1
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        },
+        "readinessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/readyz"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "initialDelaySeconds": {
+              "type": "integer",
+              "default": 0
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "successThreshold": {
+              "type": "integer",
+              "default": 1
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        },
+        "startupProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/startupz"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 136 - 0
internal/integrations/preview/embed/worker.values.schema.json

@@ -0,0 +1,136 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    },
+    "autoscaling": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "minReplicas": {
+          "type": "integer",
+          "default": 1
+        },
+        "maxReplicas": {
+          "type": "integer",
+          "default": 10
+        },
+        "targetCPUUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        },
+        "targetMemoryUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        }
+      }
+    },
+    "health": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "command": {
+          "type": "string",
+          "default": "ls -l"
+        },
+        "periodSeconds": {
+          "type": "integer",
+          "default": 5
+        },
+        "failureThreshold": {
+          "type": "integer",
+          "default": 3
+        },
+        "readinessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "command": {
+              "type": "string",
+              "default": "ls -l"
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            }
+          }
+        },
+        "startupProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "command": {
+              "type": "string",
+              "default": "ls -l"
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            }
+          }
+        }
+      }
+    }
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов