Browse Source

Merge pull request #740 from jnfrati/251-cluster-ip-address-on-dashboard

Cluster ip address on dashboard
abelanger5 5 years ago
parent
commit
2e6ebcb548

+ 33 - 0
dashboard/package-lock.json

@@ -2105,6 +2105,16 @@
         }
       }
     },
+    "clipboard": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.8.tgz",
+      "integrity": "sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==",
+      "requires": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
     "cliui": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
@@ -2710,6 +2720,11 @@
         "rimraf": "^2.6.3"
       }
     },
+    "delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
     "depd": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@@ -3950,6 +3965,14 @@
         }
       }
     },
+    "good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
+      "requires": {
+        "delegate": "^3.1.2"
+      }
+    },
     "graceful-fs": {
       "version": "4.2.4",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
@@ -6595,6 +6618,11 @@
         "ajv-keywords": "^3.5.2"
       }
     },
+    "select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+      "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
+    },
     "select-hose": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -7529,6 +7557,11 @@
         "setimmediate": "^1.0.4"
       }
     },
+    "tiny-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+    },
     "tiny-invariant": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",

+ 1 - 0
dashboard/package.json

@@ -25,6 +25,7 @@
     "anser": "^2.0.1",
     "axios": "^0.20.0",
     "brace": "^0.11.1",
+    "clipboard": "^2.0.8",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",

+ 99 - 0
dashboard/src/components/CopyToClipboard.tsx

@@ -0,0 +1,99 @@
+// import ClipboardJS from "clipboard";
+import ClipboardJS from "clipboard";
+import React, { Component, RefObject } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
+import styled from "styled-components";
+
+type PropsType = {
+  text: string;
+  onSuccess?: (e: ClipboardJS.Event) => void;
+  onError?: (e: ClipboardJS.Event) => void;
+  wrapperProps?: any;
+  as?: any;
+};
+
+type StateType = {
+  clipboard: ClipboardJS | undefined;
+  success: boolean;
+};
+
+/**
+ * Dynamic component to enable copy to clipboard.
+ *  By default, it will be displayed as a span, when the user clicks over the span
+ *  it will copy the text provided
+ *
+ * Examples of usage:
+ * <CopyToClipboard
+ *   as={MyCustomComponent}
+ *   text={`some usefull text ${var}`}
+ *   onSuccess={(e) => console.log("Success event:", e)}
+ *   onError={(e) => console.log("Error event:", e)}
+ * >
+ *   Some content
+ * </CopyToClipboard>
+ */
+export default class CopyToClipboard extends Component<PropsType, StateType> {
+  triggerRef: RefObject<HTMLSpanElement>;
+
+  state: StateType = {
+    clipboard: undefined,
+    success: false,
+  };
+
+  constructor(props: PropsType) {
+    super(props);
+    this.triggerRef = React.createRef();
+  }
+
+  componentDidMount() {
+    const trigger = this.triggerRef.current;
+    if (!trigger) {
+      console.error("Couldn't mount clipboardjs on wrapper component");
+      return;
+    }
+    const clipboard = new ClipboardJS(trigger, {
+      text: () => {
+        return this.props.text;
+      },
+    });
+
+    clipboard.on("success", (e) => {
+      this.setState({ success: true });
+      this.props.onSuccess && this.props.onSuccess(e);
+      setTimeout(() => {
+        this.setState({ success: false });
+      }, 2000);
+    });
+
+    this.props.onError && clipboard.on("error", this.props.onError);
+
+    this.setState({ clipboard });
+  }
+
+  componentWillUnmount() {
+    if (this.state.clipboard && this.state.clipboard.destroy) {
+      this.state.clipboard.destroy();
+    }
+  }
+
+  render() {
+    return (
+      <Tooltip
+        title="Copied to clipboard!"
+        open={this.state.success}
+        placement="bottom"
+        arrow
+      >
+        <DynamicSpanComponent
+          as={this.props.as || "span"}
+          ref={this.triggerRef}
+          {...(this.props.wrapperProps || {})}
+        >
+          {this.props.children}
+        </DynamicSpanComponent>
+      </Tooltip>
+    );
+  }
+}
+
+const DynamicSpanComponent = styled.span``;

+ 10 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -17,6 +17,7 @@ import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 import InputRow from "components/values-form/InputRow";
 import _ from "lodash";
+import CopyToClipboard from "components/CopyToClipboard";
 
 type PropsType = {
   currentChart: ChartType;
@@ -115,16 +116,18 @@ export default class SettingsSection extends Component<PropsType, StateType> {
           </Helper>
           <Webhook copiedToClipboard={this.state.highlightCopyButton}>
             <div>{webhookText}</div>
-            <i
-              className="material-icons"
-              onClick={() => {
-                navigator.clipboard.writeText(webhookText);
-                this.setState({ highlightCopyButton: true });
+            <CopyToClipboard
+              as="i"
+              text={webhookText}
+              onSuccess={() => this.setState({ highlightCopyButton: true })}
+              wrapperProps={{
+                className: "material-icons",
+                onMouseLeave: () =>
+                  this.setState({ highlightCopyButton: false }),
               }}
-              onMouseLeave={() => this.setState({ highlightCopyButton: false })}
             >
               content_copy
-            </i>
+            </CopyToClipboard>
           </Webhook>
         </>
       );

+ 215 - 34
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -3,12 +3,20 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ClusterType } from "shared/types";
+import {
+  ClusterType,
+  DetailedClusterType,
+  DetailedIngressError,
+} from "shared/types";
 import Helper from "components/values-form/Helper";
 import { pushFiltered } from "shared/routing";
 
 import { RouteComponentProps, withRouter } from "react-router";
 
+import CopyToClipboard from "components/CopyToClipboard";
+import Loading from "components/Loading";
+import Modal from "../modals/Modal";
+
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
 };
@@ -16,14 +24,19 @@ type PropsType = RouteComponentProps & {
 type StateType = {
   loading: boolean;
   error: string;
-  clusters: ClusterType[];
+  clusters: DetailedClusterType[];
+  showErrorModal?: {
+    clusterId: number;
+    show: boolean;
+  };
 };
 
 class Templates extends Component<PropsType, StateType> {
-  state = {
+  state: StateType = {
     loading: true,
     error: "",
-    clusters: [] as ClusterType[],
+    clusters: [],
+    showErrorModal: undefined,
   };
 
   componentDidMount() {
@@ -36,17 +49,50 @@ class Templates extends Component<PropsType, StateType> {
     }
   }
 
-  updateClusterList = () => {
-    api
-      .getClusters("<token>", {}, { id: this.context.currentProject.id })
-      .then((res) => {
-        if (res.data) {
-          this.setState({ clusters: res.data, loading: false, error: "" });
-        } else {
-          this.setState({ loading: false, error: "Response data missing" });
-        }
-      })
-      .catch((err) => this.setState(err));
+  updateClusterList = async () => {
+    try {
+      const res = await api.getClusters(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      );
+
+      if (res.data) {
+        this.setState({ clusters: res.data, loading: false, error: "" });
+
+        this.state.clusters.forEach((cluster) => {
+          this.updateClusterWithDetailedData(cluster.id);
+        });
+      } else {
+        this.setState({ loading: false, error: "Response data missing" });
+      }
+    } catch (err) {
+      this.setState(err);
+    }
+  };
+
+  updateClusterWithDetailedData = async (clusterId: number) => {
+    try {
+      const currentClusterIndex = this.state.clusters.findIndex(
+        (cluster) => cluster.id === clusterId
+      );
+      const res = await api.getCluster(
+        "<token>",
+        {},
+        { project_id: this.context.currentProject.id, cluster_id: clusterId }
+      );
+      if (res.data) {
+        this.setState((prevState) => {
+          const currentCluster = prevState.clusters[currentClusterIndex];
+          prevState.clusters.splice(currentClusterIndex, 1, {
+            ...currentCluster,
+            ingress_ip: res.data.ingress_ip,
+            ingress_error: res.data.ingress_error,
+          });
+          return prevState;
+        });
+      }
+    } catch (error) {}
   };
 
   renderIcon = () => {
@@ -57,23 +103,107 @@ class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderClusters = () => {
-    return this.state.clusters.map((cluster: ClusterType, i: number) => {
+  renderIngressIp = (
+    clusterId: number,
+    ingressIp: string | undefined,
+    ingressError: DetailedIngressError
+  ) => {
+    if (typeof ingressIp !== "string") {
+      return (
+        <Url onClick={(e) => e.preventDefault()}>
+          <Loading />
+        </Url>
+      );
+    }
+
+    if (!ingressIp.length && ingressError) {
+      return (
+        <>
+          <Url
+            onClick={(e) => {
+              e.stopPropagation();
+              this.setState({ showErrorModal: { clusterId, show: true } });
+            }}
+          >
+            <Bolded>Ingress IP:</Bolded>
+            <span>{ingressError.message}</span>
+            <i className="material-icons">launch</i>
+          </Url>
+        </>
+      );
+    }
+
+    if (!ingressIp.length) {
       return (
-        <TemplateBlock
-          onClick={() => {
-            this.context.setCurrentCluster(cluster);
-            pushFiltered(this.props, "/applications", ["project_id"], {
-              cluster: cluster.name,
-            });
-          }}
-          key={i}
-        >
-          {this.renderIcon()}
-          <TemplateTitle>{cluster.name}</TemplateTitle>
-        </TemplateBlock>
+        <Url>
+          <Bolded>Ingress IP:</Bolded>
+          <span>Ingress IP not available</span>
+        </Url>
       );
-    });
+    }
+
+    return (
+      <CopyToClipboard
+        as={Url}
+        text={ingressIp}
+        wrapperProps={{ onClick: (e: any) => e.stopPropagation() }}
+      >
+        <Bolded>Ingress IP:</Bolded>
+        <span>{ingressIp}</span>
+        <i className="material-icons-outlined">content_copy</i>
+      </CopyToClipboard>
+    );
+  };
+
+  renderClusters = () => {
+    return this.state.clusters.map(
+      (cluster: DetailedClusterType, i: number) => {
+        return (
+          <TemplateBlock
+            onClick={() => {
+              this.context.setCurrentCluster(cluster);
+              pushFiltered(this.props, "/applications", ["project_id"], {
+                cluster: cluster.name,
+              });
+            }}
+            key={i}
+          >
+            <TitleContainer>
+              {this.renderIcon()}
+              <TemplateTitle>{cluster.name}</TemplateTitle>
+            </TitleContainer>
+            {this.renderIngressIp(
+              cluster.id,
+              cluster.ingress_ip,
+              cluster.ingress_error
+            )}
+          </TemplateBlock>
+        );
+      }
+    );
+  };
+
+  renderErrorModal = () => {
+    const clusterError =
+      this.state.showErrorModal?.show &&
+      this.state.clusters.find(
+        (c) => c.id === this.state.showErrorModal?.clusterId
+      );
+    const ingressError = clusterError?.ingress_error;
+    return (
+      <>
+        {clusterError && (
+          <Modal
+            onRequestClose={() => this.setState({ showErrorModal: undefined })}
+            width="665px"
+            height="min-content"
+          >
+            Porter encountered an error. Full error log:
+            <CodeBlock>{ingressError.error}</CodeBlock>
+          </Modal>
+        )}
+      </>
+    );
   };
 
   render() {
@@ -81,6 +211,7 @@ class Templates extends Component<PropsType, StateType> {
       <StyledClusterList>
         <Helper>Clusters connected to this project:</Helper>
         <TemplateList>{this.renderClusters()}</TemplateList>
+        {this.renderErrorModal()}
       </StyledClusterList>
     );
   }
@@ -90,11 +221,34 @@ Templates.contextType = Context;
 
 export default withRouter(Templates);
 
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
+`;
+
 const StyledClusterList = styled.div`
   margin-top: -17px;
   padding-left: 2px;
 `;
 
+const TitleContainer = styled.div`
+  display: flex;
+  width: 100%;
+  flex-direction: column;
+  align-items: center;
+`;
 const DashboardIcon = styled.div`
   position: relative;
   height: 45px;
@@ -106,19 +260,20 @@ const DashboardIcon = styled.div`
   justify-content: center;
   background: #676c7c;
   border: 2px solid #8e94aa;
-
+  margin-bottom: 10px;
   > i {
     font-size: 22px;
   }
 `;
 
 const TemplateTitle = styled.div`
-  margin-bottom: 26px;
+  margin-bottom: 0px;
   width: 80%;
   text-align: center;
   font-size: 14px;
   white-space: nowrap;
   overflow: hidden;
+  white-space: nowrap;
   text-overflow: ellipsis;
 `;
 
@@ -130,11 +285,11 @@ const TemplateBlock = styled.div`
   display: flex;
   font-size: 13px;
   font-weight: 500;
-  padding: 35px 10px 12px;
+  padding: 35px;
   flex-direction: column;
   align-item: center;
   justify-content: space-between;
-  height: 165px;
+  height: 192px;
   cursor: pointer;
   color: #ffffff;
   position: relative;
@@ -202,3 +357,29 @@ const TemplatesWrapper = styled.div`
   min-width: 300px;
   padding-top: 50px;
 `;
+
+const Url = styled.a`
+  width: 100%;
+  font-size: 13px;
+  user-select: text;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+`;
+
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+  white-space: nowrap;
+`;

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

@@ -9,6 +9,7 @@ import Loading from "components/Loading";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
+import CopyToClipboard from "components/CopyToClipboard";
 
 type PropsType = {};
 
@@ -113,22 +114,13 @@ export default class InviteList extends Component<PropsType, StateType> {
       .catch((err) => console.log(err));
   };
 
-  copyToClip = (index: number) => {
+  getInviteUrl = (index: number) => {
     let { currentProject } = this.context;
-    navigator.clipboard
-      .writeText(
-        `${this.state.isHTTPS ? "https://" : ""}${
-          window.location.host
-        }/api/projects/${currentProject.id}/invites/${
-          this.state.invites[index].token
-        }`
-      )
-      .then(
-        function () {},
-        function () {
-          console.log("couldn't copy link to clipboard");
-        }
-      );
+    return `${this.state.isHTTPS ? "https://" : ""}${
+      window.location.host
+    }/api/projects/${currentProject.id}/invites/${
+      this.state.invites[index].token
+    }`;
   };
 
   renderInvitations = () => {
@@ -188,9 +180,13 @@ export default class InviteList extends Component<PropsType, StateType> {
                     }`}
                     placeholder="Unable to retrieve link"
                   />
-                  <CopyButton onClick={() => this.copyToClip(i)}>
+                  <CopyToClipboard
+                    as={CopyButton}
+                    text={this.getInviteUrl(i)}
+                    onError={() => console.log("Couldn't copy to clipboard")}
+                  >
                     Copy Link
-                  </CopyButton>
+                  </CopyToClipboard>
                 </Rower>
               </LinkTd>
               <Td isTop={i === 0}>

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

@@ -389,6 +389,16 @@ const getClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
+const getCluster = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
+});
+
 const getGitRepoList = baseApi<
   {},
   {
@@ -869,6 +879,7 @@ export default {
   getChartControllers,
   getClusterIntegrations,
   getClusters,
+  getCluster,
   getConfigMap,
   getGitRepoList,
   getGitRepos,

+ 10 - 0
dashboard/src/shared/types.tsx

@@ -7,6 +7,16 @@ export interface ClusterType {
   service?: string;
 }
 
+export interface DetailedClusterType extends ClusterType {
+  ingress_ip?: string;
+  ingress_error?: DetailedIngressError
+}
+
+export interface DetailedIngressError {
+  message: string;
+  error: string
+}
+
 export interface ChartType {
   name: string;
   info: {

+ 99 - 0
internal/kubernetes/errors.go

@@ -0,0 +1,99 @@
+package kubernetes
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"os"
+	"syscall"
+
+	k8sErrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+type ErrExternalized struct {
+	error
+	Message string `json:"message"`
+	Details string `json:"error"`
+}
+
+type K8sConnectionError interface {
+	Externalize() *ErrExternalized
+	Error() string
+}
+
+func CatchK8sConnectionError(err error) K8sConnectionError {
+	if uerr, ok := err.(*url.Error); ok {
+		if noerr, ok := uerr.Err.(*net.OpError); ok {
+			if scerr, ok := noerr.Err.(*os.SyscallError); ok {
+				if scerr.Err == syscall.ECONNREFUSED {
+					return &ErrConnection{
+						k8sErr: err,
+					}
+				}
+			}
+		}
+	}
+
+	if k8sErrors.IsTimeout(err) {
+		return &ErrConnection{
+			k8sErr: err,
+		}
+	}
+
+	if k8sErrors.IsUnauthorized(err) || k8sErrors.IsForbidden(err) {
+		return &ErrUnauthorized{
+			k8sErr: err,
+		}
+	}
+
+	return &ErrUnknown{
+		k8sErr: err,
+	}
+}
+
+type ErrUnknown struct {
+	k8sErr error
+}
+
+func (e *ErrUnknown) Error() string {
+	return fmt.Sprintf("Unknown or unhandled error: %s", e.k8sErr.Error())
+}
+
+func (e *ErrUnknown) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Unknown or unhandled error",
+		Details: e.Error(),
+	}
+}
+
+// For ECONNREFUSED and errors.IsTimeout
+type ErrConnection struct {
+	k8sErr error
+}
+
+func (e *ErrConnection) Error() string {
+	return fmt.Sprintf("Could not connect to cluster: %s", e.k8sErr.Error())
+}
+
+func (e *ErrConnection) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Could not connect to cluster",
+		Details: e.Error(),
+	}
+}
+
+// For errors.IsForbidden and errors.IsUnauthorized
+type ErrUnauthorized struct {
+	k8sErr error
+}
+
+func (e *ErrUnauthorized) Error() string {
+	return fmt.Sprintf("Unauthorized: %s", e.k8sErr.Error())
+}
+
+func (e *ErrUnauthorized) Externalize() *ErrExternalized {
+	return &ErrExternalized{
+		Message: "Unauthorized",
+		Details: e.Error(),
+	}
+}

+ 19 - 0
internal/models/cluster.go

@@ -110,6 +110,25 @@ func (c *Cluster) Externalize() *ClusterExternal {
 	}
 }
 
+type ClusterDetailedExternal struct {
+	// Simple cluster external data
+	ClusterExternal
+
+	// The NGINX Ingress IP to access the cluster
+	IngressIP string `json:"ingress_ip"`
+
+	// Error displayed in case couldn't get the IP
+	IngressError error `json:"ingress_error"`
+}
+
+func (c *Cluster) DetailedExternalize() *ClusterDetailedExternal {
+	clusterExt := c.Externalize()
+
+	return &ClusterDetailedExternal{
+		ClusterExternal: *clusterExt,
+	}
+}
+
 // ClusterCandidate is a cluster integration that requires additional action
 // from the user to set up.
 type ClusterCandidate struct {

+ 30 - 1
server/api/cluster_handler.go

@@ -7,6 +7,8 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -79,7 +81,33 @@ func (app *App) HandleReadProjectCluster(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	clusterExt := cluster.Externalize()
+	clusterExt := cluster.DetailedExternalize()
+
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+			Cluster:           cluster,
+		},
+	}
+
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, _ = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	endpoint, found, ingressErr := domain.GetNGINXIngressServiceIP(agent.Clientset)
+
+	if found {
+		clusterExt.IngressIP = endpoint
+	}
+
+	if !found && ingressErr != nil {
+		clusterExt.IngressError = kubernetes.CatchK8sConnectionError(ingressErr).Externalize()
+	}
 
 	w.WriteHeader(http.StatusOK)
 
@@ -107,6 +135,7 @@ func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request
 
 	extClusters := make([]*models.ClusterExternal, 0)
 
+
 	for _, cluster := range clusters {
 		extClusters = append(extClusters, cluster.Externalize())
 	}