diff --git a/dashboard/client/src/App.tsx b/dashboard/client/src/App.tsx
index 6647b4129cf6..323b9b28c68b 100644
--- a/dashboard/client/src/App.tsx
+++ b/dashboard/client/src/App.tsx
@@ -30,12 +30,16 @@ import {
ServeApplicationDetailPage,
} from "./pages/serve/ServeApplicationDetailPage";
import { ServeApplicationsListPage } from "./pages/serve/ServeApplicationsListPage";
-import { ServeLayout } from "./pages/serve/ServeLayout";
+import { ServeLayout, ServeSideTabLayout } from "./pages/serve/ServeLayout";
import { ServeReplicaDetailPage } from "./pages/serve/ServeReplicaDetailPage";
import {
ServeControllerDetailPage,
ServeHttpProxyDetailPage,
} from "./pages/serve/ServeSystemActorDetailPage";
+import {
+ ServeSystemDetailLayout,
+ ServeSystemDetailPage,
+} from "./pages/serve/ServeSystemDetailPage";
import { TaskPage } from "./pages/task/TaskPage";
import { getNodeList } from "./service/node";
import { lightTheme } from "./theme";
@@ -225,15 +229,34 @@ const App = () => {
} path="metrics" />
} path="serve">
- } path="" />
- }
- path="controller"
- />
- }
- path="httpProxies/:httpProxyId"
- />
+ } path="">
+
+
+
+ }
+ path="system"
+ />
+
+
+
+ }
+ path=""
+ />
+
+ } path="system">
+ }
+ path="controller"
+ />
+ }
+ path="httpProxies/:httpProxyId"
+ />
+
}
path="applications/:applicationName"
diff --git a/dashboard/client/src/components/MetadataSection/MetadataSection.tsx b/dashboard/client/src/components/MetadataSection/MetadataSection.tsx
index f9091a78a62e..726988f06d67 100644
--- a/dashboard/client/src/components/MetadataSection/MetadataSection.tsx
+++ b/dashboard/client/src/components/MetadataSection/MetadataSection.tsx
@@ -197,13 +197,16 @@ const MetadataList: React.FC<{
export const MetadataSection = ({
header,
metadataList,
+ footer,
}: {
header?: string;
metadataList: Metadata[];
+ footer?: JSX.Element;
}) => {
return (
);
};
diff --git a/dashboard/client/src/components/StatusChip.tsx b/dashboard/client/src/components/StatusChip.tsx
index 4919d5465b08..db630b92e3a4 100644
--- a/dashboard/client/src/components/StatusChip.tsx
+++ b/dashboard/client/src/components/StatusChip.tsx
@@ -61,6 +61,7 @@ const colorMap = {
[ServeApplicationStatus.RUNNING]: green,
[ServeApplicationStatus.DEPLOY_FAILED]: red,
[ServeApplicationStatus.DELETING]: orange,
+ [ServeApplicationStatus.UNHEALTHY]: red,
},
serveDeployment: {
[ServeDeploymentStatus.UPDATING]: orange,
@@ -106,7 +107,6 @@ const useStyles = makeStyles((theme) =>
border: "solid 1px",
borderRadius: 4,
fontSize: 12,
- margin: 2,
display: "inline-flex",
alignItems: "center",
},
@@ -116,17 +116,14 @@ const useStyles = makeStyles((theme) =>
}),
);
-export const StatusChip = ({
- type,
- status,
- suffix,
- icon,
-}: {
- type: string;
+export type StatusChipProps = {
+ type: keyof typeof colorMap;
status: string | ActorEnum | ReactNode;
- suffix?: string;
+ suffix?: ReactNode;
icon?: ReactNode;
-}) => {
+};
+
+export const StatusChip = ({ type, status, suffix, icon }: StatusChipProps) => {
const classes = useStyles();
let color: Color | string = blueGrey;
diff --git a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
index ce8a0c6632db..0533091c33a3 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
@@ -22,7 +22,7 @@ const mockGetServeApplications = jest.mocked(getServeApplications);
describe("ServeApplicationDetailPage", () => {
it("renders page with deployments and replicas", async () => {
- expect.assertions(19);
+ expect.assertions(22);
mockUseParams.mockReturnValue({
applicationName: "home",
@@ -146,18 +146,23 @@ describe("ServeApplicationDetailPage", () => {
expect(screen.getByText(/test-config: 1/)).toBeVisible();
expect(screen.getByText(/autoscaling-value: 2/)).toBeVisible();
- // Expand the first deployment
- await user.click(screen.getAllByTitle("Expand")[0]);
- await screen.findByText("test-replica-1");
+ // All deployments are already expanded
expect(screen.getByText("test-replica-1")).toBeVisible();
expect(screen.getByText("test-replica-2")).toBeVisible();
- expect(screen.queryByText("test-replica-3")).toBeNull();
+ expect(screen.getByText("test-replica-3")).toBeVisible();
// Collapse the first deployment
- await user.click(screen.getByTitle("Collapse"));
+ await user.click(screen.getAllByTitle("Collapse")[0]);
await waitFor(() => screen.queryByText("test-replica-1") === null);
expect(screen.queryByText("test-replica-1")).toBeNull();
expect(screen.queryByText("test-replica-2")).toBeNull();
- expect(screen.queryByText("test-replica-3")).toBeNull();
+ expect(screen.getByText("test-replica-3")).toBeVisible();
+
+ // Expand the first deployment again
+ await user.click(screen.getByTitle("Expand"));
+ await screen.findByText("test-replica-1");
+ expect(screen.getByText("test-replica-1")).toBeVisible();
+ expect(screen.getByText("test-replica-2")).toBeVisible();
+ expect(screen.getByText("test-replica-3")).toBeVisible();
});
});
diff --git a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx
index f92e4b741058..837b6f5256f2 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx
@@ -30,6 +30,9 @@ import { ServeDeploymentRow } from "./ServeDeploymentRow";
const useStyles = makeStyles((theme) =>
createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
table: {
tableLayout: "fixed",
},
@@ -76,8 +79,17 @@ export const ServeApplicationDetailPage = () => {
}
const appName = application.name ? application.name : "-";
+ // Expand all deployments if there is only 1 deployment or
+ // there are less than 10 replicas across all deployments.
+ const deploymentsStartExpanded =
+ Object.keys(application.deployments).length === 1 ||
+ Object.values(application.deployments).reduce(
+ (acc, deployment) => acc + deployment.replicas.length,
+ 0,
+ ) < 10;
+
return (
-
+
{
key={deployment.name}
deployment={deployment}
application={application}
+ startExpanded={deploymentsStartExpanded}
/>
))}
diff --git a/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx b/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx
index c6c74cb12d4f..a4fc9bff03ce 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx
@@ -18,7 +18,7 @@ const mockGetActor = jest.mocked(getActor);
describe("ServeApplicationsListPage", () => {
it("renders list", async () => {
- expect.assertions(14);
+ expect.assertions(5);
// Mock ServeController actor fetch
mockGetActor.mockResolvedValue({
@@ -79,29 +79,15 @@ describe("ServeApplicationsListPage", () => {
} as any);
render(, { wrapper: TEST_APP_WRAPPER });
-
- await screen.findByText("System");
- expect(screen.getByText("System")).toBeVisible();
- expect(screen.getByText("1.2.3.4")).toBeVisible();
- expect(screen.getByText("8000")).toBeVisible();
-
- // HTTP Proxy row
- expect(screen.getByText("HTTPProxyActor:node:12345")).toBeVisible();
- expect(screen.getByText("STARTING")).toBeVisible();
-
- // Serve Controller row
- expect(screen.getByText("Serve Controller")).toBeVisible();
- expect(screen.getByText("HEALTHY")).toBeVisible();
+ await screen.findByText("Application status");
// First row
expect(screen.getByText("home")).toBeVisible();
expect(screen.getByText("/")).toBeVisible();
- expect(screen.getByText("RUNNING")).toBeVisible();
// Second row
expect(screen.getByText("second-app")).toBeVisible();
expect(screen.getByText("/second-app")).toBeVisible();
- expect(screen.getByText("DEPLOYING")).toBeVisible();
expect(screen.getByText("Metrics")).toBeVisible();
});
diff --git a/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx b/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx
index c2523bf32cba..d6d86caa6617 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx
@@ -16,15 +16,25 @@ import {
import { Alert, Autocomplete, Pagination } from "@material-ui/lab";
import React, { ReactElement } from "react";
import { CollapsibleSection } from "../../common/CollapsibleSection";
+import {
+ MultiTabLogViewer,
+ MultiTabLogViewerTabDetails,
+} from "../../common/MultiTabLogViewer";
+import { Section } from "../../common/Section";
import Loading from "../../components/Loading";
import { HelpInfo } from "../../components/Tooltip";
+import { ServeSystemActor } from "../../type/serve";
+import { useFetchActor } from "../actor/hook/useActorDetail";
import { useServeApplications } from "./hook/useServeApplications";
import { ServeApplicationRow } from "./ServeApplicationRow";
import { ServeMetricsSection } from "./ServeMetricsSection";
-import { ServeSystemDetails } from "./ServeSystemDetails";
+import { ServeSystemPreview } from "./ServeSystemDetails";
const useStyles = makeStyles((theme) =>
createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
table: {
tableLayout: "fixed",
},
@@ -37,7 +47,7 @@ const useStyles = makeStyles((theme) =>
applicationsSection: {
marginTop: theme.spacing(4),
},
- metricsSection: {
+ section: {
marginTop: theme.spacing(4),
},
}),
@@ -59,13 +69,11 @@ export const ServeApplicationsListPage = () => {
const {
serveDetails,
filteredServeApplications,
- httpProxies,
error,
allServeApplications,
page,
setPage,
- httpProxiesPage,
- setHttpProxiesPage,
+ httpProxies,
changeFilter,
} = useServeApplications();
@@ -78,21 +86,20 @@ export const ServeApplicationsListPage = () => {
}
return (
-
+
{serveDetails.http_options === undefined ? (
Serve not started. Please deploy a serve application first.
) : (
-
@@ -192,9 +199,55 @@ export const ServeApplicationsListPage = () => {
+
+
+
)}
-
+
);
};
+
+type ServeControllerLogsProps = {
+ controller: ServeSystemActor;
+};
+
+const ServeControllerLogs = ({
+ controller: { actor_id, log_file_path },
+}: ServeControllerLogsProps) => {
+ const { data: fetchedActor } = useFetchActor(actor_id);
+
+ if (!fetchedActor || !log_file_path) {
+ return
;
+ }
+
+ const tabs: MultiTabLogViewerTabDetails[] = [
+ {
+ title: "Controller logs",
+ nodeId: fetchedActor.address.rayletId,
+ filename: log_file_path.startsWith("/")
+ ? log_file_path.substring(1)
+ : log_file_path,
+ },
+ {
+ title: "Other logs",
+ contents:
+ "Replica logs contain the application logs emitted by each Serve Replica.\n" +
+ "To view replica logs, please click into a Serve application from " +
+ "the table above to enter the Application details page.\nThen, click " +
+ "into a Serve Replica in the Deployments table.\n\n" +
+ "HTTP Proxy logs contains HTTP access logs for each HTTP Proxy.\n" +
+ "To view HTTP Proxy logs, click into a HTTP Proxy from the Serve System " +
+ "Details page.\nThis page can be accessed via the left tab menu or by " +
+ 'clicking "View system status and configuration" link above.',
+ },
+ ];
+ return
;
+};
diff --git a/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx b/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
index 9b7892a2dda4..347b822dcdcb 100644
--- a/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
+++ b/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
@@ -43,17 +43,19 @@ const useStyles = makeStyles((theme) =>
export type ServeDeployentRowProps = {
deployment: ServeDeployment;
application: ServeApplication;
+ startExpanded?: boolean;
};
export const ServeDeploymentRow = ({
deployment,
application: { last_deployed_time_s },
+ startExpanded = false,
}: ServeDeployentRowProps) => {
const { name, status, message, deployment_config, replicas } = deployment;
const classes = useStyles();
- const [expanded, setExpanded] = useState(false);
+ const [expanded, setExpanded] = useState(startExpanded);
const metricsUrl = useViewServeDeploymentMetricsButtonUrl(name);
return (
diff --git a/dashboard/client/src/pages/serve/ServeLayout.tsx b/dashboard/client/src/pages/serve/ServeLayout.tsx
index 05787efc11ec..f58e61d19a02 100644
--- a/dashboard/client/src/pages/serve/ServeLayout.tsx
+++ b/dashboard/client/src/pages/serve/ServeLayout.tsx
@@ -1,12 +1,13 @@
import { createStyles, makeStyles } from "@material-ui/core";
import React from "react";
+import { RiInformationLine, RiTableLine } from "react-icons/ri";
import { Outlet } from "react-router-dom";
import { MainNavPageInfo } from "../layout/mainNavContext";
+import { SideTabLayout, SideTabRouteLink } from "../layout/SideTabLayout";
const useStyles = makeStyles((theme) =>
createStyles({
root: {
- padding: theme.spacing(3),
width: "100%",
minHeight: 800,
background: "white",
@@ -30,3 +31,21 @@ export const ServeLayout = () => {
);
};
+
+export const ServeSideTabLayout = () => {
+ return (
+
+
+
+
+ );
+};
diff --git a/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx b/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx
index d1572729f26e..359a4f65196d 100644
--- a/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx
@@ -22,6 +22,9 @@ import { ServeReplicaMetricsSection } from "./ServeDeploymentMetricsSection";
const useStyles = makeStyles((theme) =>
createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
section: {
marginTop: theme.spacing(4),
},
@@ -61,7 +64,7 @@ export const ServeReplicaDetailPage = () => {
start_time_s,
} = replica;
return (
-
+
+ createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
+ }),
+);
+
export const ServeHttpProxyDetailPage = () => {
+ const classes = useStyles();
const { httpProxyId } = useParams();
const { httpProxy, loading } = useServeHTTPProxyDetails(httpProxyId);
@@ -42,7 +51,7 @@ export const ServeHttpProxyDetailPage = () => {
}
return (
-
+
{
};
export const ServeControllerDetailPage = () => {
+ const classes = useStyles();
const { controller, loading } = useServeControllerDetails();
if (loading) {
@@ -80,7 +90,7 @@ export const ServeControllerDetailPage = () => {
}
return (
-
+
{
+ it("renders list", async () => {
+ expect.assertions(7);
+
+ // Mock ServeController actor fetch
+ mockGetActor.mockResolvedValue({
+ data: {
+ data: {
+ detail: {
+ state: "ALIVE",
+ },
+ },
+ },
+ } as any);
+
+ mockGetServeApplications.mockResolvedValue({
+ data: {
+ http_options: { host: "1.2.3.4", port: 8000 },
+ http_proxies: {
+ foo: {
+ node_id: "node:12345",
+ status: ServeSystemActorStatus.STARTING,
+ actor_id: "actor:12345",
+ },
+ },
+ controller_info: {
+ node_id: "node:12345",
+ actor_id: "actor:12345",
+ },
+ proxy_location: ServeDeploymentMode.EveryNode,
+ applications: {
+ home: {
+ name: "home",
+ route_prefix: "/",
+ message: null,
+ status: ServeApplicationStatus.RUNNING,
+ deployed_app_config: {
+ import_path: "home:graph",
+ },
+ last_deployed_time_s: new Date().getTime() / 1000,
+ deployments: {
+ FirstDeployment: {},
+ SecondDeployment: {},
+ },
+ },
+ "second-app": {
+ name: "second-app",
+ route_prefix: "/second-app",
+ message: null,
+ status: ServeApplicationStatus.DEPLOYING,
+ deployed_app_config: {
+ import_path: "second_app:graph",
+ },
+ last_deployed_time_s: new Date().getTime() / 1000,
+ deployments: {
+ ThirdDeployment: {},
+ },
+ },
+ },
+ },
+ } as any);
+
+ render(, { wrapper: TEST_APP_WRAPPER });
+
+ await screen.findByText("System");
+ expect(screen.getByText("System")).toBeVisible();
+ expect(screen.getByText("1.2.3.4")).toBeVisible();
+ expect(screen.getByText("8000")).toBeVisible();
+
+ // HTTP Proxy row
+ expect(screen.getByText("HTTPProxyActor:node:12345")).toBeVisible();
+ expect(screen.getByText("STARTING")).toBeVisible();
+
+ // Serve Controller row
+ expect(screen.getByText("Serve Controller")).toBeVisible();
+ expect(screen.getByText("HEALTHY")).toBeVisible();
+ });
+});
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx b/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx
new file mode 100644
index 000000000000..6539e275614e
--- /dev/null
+++ b/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx
@@ -0,0 +1,76 @@
+import { createStyles, makeStyles, Typography } from "@material-ui/core";
+import { Alert } from "@material-ui/lab";
+import React from "react";
+import { Outlet } from "react-router-dom";
+import Loading from "../../components/Loading";
+import { MainNavPageInfo } from "../layout/mainNavContext";
+import { useServeApplications } from "./hook/useServeApplications";
+import { ServeSystemDetails } from "./ServeSystemDetails";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
+ serveInstanceWarning: {
+ marginBottom: theme.spacing(2),
+ },
+ }),
+);
+
+export const ServeSystemDetailPage = () => {
+ const classes = useStyles();
+
+ const {
+ serveDetails,
+ httpProxies,
+ httpProxiesPage,
+ setHttpProxiesPage,
+ error,
+ } = useServeApplications();
+
+ if (error) {
+ return {error.toString()};
+ }
+
+ if (serveDetails === undefined) {
+ return ;
+ }
+
+ return (
+
+
+ {serveDetails.http_options === undefined ? (
+
+ Serve not started. Please deploy a serve application first.
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export const ServeSystemDetailLayout = () => (
+
+
+
+
+);
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx b/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx
new file mode 100644
index 000000000000..53604f170868
--- /dev/null
+++ b/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { ActorEnum } from "../../type/actor";
+import {
+ ServeApplicationStatus,
+ ServeSystemActorStatus,
+} from "../../type/serve";
+import { TEST_APP_WRAPPER } from "../../util/test-utils";
+import { useFetchActor } from "../actor/hook/useActorDetail";
+import { ServeSystemPreview } from "./ServeSystemDetails";
+
+jest.mock("../actor/hook/useActorDetail");
+const mockedUseFetchActor = jest.mocked(useFetchActor);
+
+describe("ServeSystemDetails", () => {
+ it("renders", async () => {
+ expect.assertions(7);
+
+ mockedUseFetchActor.mockReturnValue({
+ data: {
+ state: ActorEnum.ALIVE,
+ },
+ } as any);
+
+ render(
+ ,
+ { wrapper: TEST_APP_WRAPPER },
+ );
+ await screen.findByText("STARTING");
+ // Controller and HTTP Proxy
+ expect(screen.getAllByText("HEALTHY")).toHaveLength(2);
+ expect(screen.getByText("STARTING")).toBeInTheDocument();
+ // Applications
+ expect(screen.getByText("RUNNING")).toBeInTheDocument();
+ expect(screen.getByText("DEPLOYING")).toBeInTheDocument();
+ expect(screen.getByText("UNHEALTHY")).toBeInTheDocument();
+
+ expect(
+ screen.getByText(/View system status and configuration/),
+ ).toBeInTheDocument();
+
+ expect(mockedUseFetchActor).toBeCalledWith("actor_id");
+ });
+});
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetails.tsx b/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
index d709b73945da..00fbcb36cbfb 100644
--- a/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
+++ b/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
@@ -8,27 +8,34 @@ import {
TableContainer,
TableHead,
TableRow,
+ Typography,
} from "@material-ui/core";
import { Pagination } from "@material-ui/lab";
+import _ from "lodash";
import React, { ReactElement } from "react";
-import { RiErrorWarningFill } from "react-icons/ri";
-import { CollapsibleSection } from "../../common/CollapsibleSection";
+import Loading from "../../components/Loading";
import { MetadataSection } from "../../components/MetadataSection";
+import { StatusChip, StatusChipProps } from "../../components/StatusChip";
import { HelpInfo } from "../../components/Tooltip";
-import { ServeApplicationsRsp, ServeHttpProxy } from "../../type/serve";
+import {
+ ServeApplication,
+ ServeApplicationsRsp,
+ ServeHttpProxy,
+} from "../../type/serve";
+import { useFetchActor } from "../actor/hook/useActorDetail";
+import { LinkWithArrow } from "../overview/cards/OverviewCard";
+import { convertActorStateForServeController } from "./ServeSystemActorDetailPage";
import { ServeControllerRow, ServeHttpProxyRow } from "./ServeSystemDetailRows";
const useStyles = makeStyles((theme) =>
createStyles({
table: {},
+ title: {
+ marginBottom: theme.spacing(2),
+ },
helpInfo: {
marginLeft: theme.spacing(1),
},
- errorIcon: {
- color: theme.palette.error.main,
- width: 20,
- height: 20,
- },
}),
);
@@ -60,18 +67,11 @@ export const ServeSystemDetails = ({
}: ServeSystemDetailsProps) => {
const classes = useStyles();
- const isUnhealthy = httpProxies.some(({ status }) => status === "UNHEALTHY");
-
return (
-
- ) : undefined
- }
- >
+
+
+ System
+
{serveDetails.http_options && (
-
+
+ );
+};
+
+type ServeSystemPreviewProps = {
+ serveDetails: ServeDetails;
+ httpProxies: ServeHttpProxy[];
+ allApplications: ServeApplication[];
+};
+
+export const ServeSystemPreview = ({
+ serveDetails,
+ httpProxies,
+ allApplications,
+}: ServeSystemPreviewProps) => {
+ const { data: controllerActor } = useFetchActor(
+ serveDetails.controller_info.actor_id,
+ );
+
+ if (!controllerActor) {
+ return ;
+ }
+
+ return (
+
+
+ ),
+ },
+ {
+ label: "HTTP Proxy status",
+ content: (
+
+ ),
+ },
+ {
+ label: "Application status",
+ content: (
+
+ ),
+ },
+ ]}
+ footer={
+
+ }
+ />
+
+ );
+};
+
+type StatusCountChipsProps = {
+ elements: T[];
+ statusKey: keyof T;
+ type: StatusChipProps["type"];
+};
+
+const StatusCountChips = ({
+ elements,
+ statusKey,
+ type,
+}: StatusCountChipsProps) => {
+ const statusCounts = _.mapValues(
+ _.groupBy(elements, statusKey),
+ (group) => group.length,
+ );
+
+ return (
+
+ {_.orderBy(
+ Object.entries(statusCounts),
+ ([, count]) => count,
+ "desc",
+ ).map(([status, count]) => (
+ {`x ${count}`}}
+ />
+ ))}
+
);
};