diff --git a/dashboard/client/src/App.tsx b/dashboard/client/src/App.tsx
index 19d37146230d..d3a51aee56ac 100644
--- a/dashboard/client/src/App.tsx
+++ b/dashboard/client/src/App.tsx
@@ -33,7 +33,11 @@ import {
ServeApplicationDetailLayout,
ServeApplicationDetailPage,
} from "./pages/serve/ServeApplicationDetailPage";
-import { ServeApplicationsListPage } from "./pages/serve/ServeApplicationsListPage";
+import {
+ ServeDeploymentDetailLayout,
+ ServeDeploymentDetailPage,
+} from "./pages/serve/ServeDeploymentDetailPage";
+import { ServeDeploymentsListPage } from "./pages/serve/ServeDeploymentsListPage";
import { ServeLayout, ServeSideTabLayout } from "./pages/serve/ServeLayout";
import { ServeReplicaDetailLayout } from "./pages/serve/ServeReplicaDetailLayout";
import { ServeReplicaDetailPage } from "./pages/serve/ServeReplicaDetailPage";
@@ -250,8 +254,8 @@ const App = () => {
/>
-
+
+
}
path=""
@@ -273,11 +277,17 @@ const App = () => {
>
} path="" />
}
- path=":deploymentName/:replicaId"
+ element={}
+ path=":deploymentName"
>
- } path="" />
- } />
+ } path="" />
+ }
+ path=":replicaId"
+ >
+ } path="" />
+ } />
+
diff --git a/dashboard/client/src/common/JobStatus.tsx b/dashboard/client/src/common/JobStatus.tsx
index 09473674e031..37222029f6fb 100644
--- a/dashboard/client/src/common/JobStatus.tsx
+++ b/dashboard/client/src/common/JobStatus.tsx
@@ -1,6 +1,7 @@
import { Box, createStyles, makeStyles } from "@material-ui/core";
import classNames from "classnames";
import React from "react";
+import { IconBaseProps } from "react-icons";
import {
RiCheckboxCircleFill,
RiCloseCircleFill,
@@ -39,11 +40,12 @@ const useJobRunningIconStyles = makeStyles((theme) =>
}),
);
-type JobRunningIconProps = { small?: boolean } & ClassNameProps;
+type JobRunningIconProps = { small?: boolean } & ClassNameProps & IconBaseProps;
export const JobRunningIcon = ({
className,
small = false,
+ ...props
}: JobRunningIconProps) => {
const classes = useJobRunningIconStyles();
return (
@@ -56,6 +58,7 @@ export const JobRunningIcon = ({
},
className,
)}
+ {...props}
/>
);
};
diff --git a/dashboard/client/src/common/ServeStatus.component.test.tsx b/dashboard/client/src/common/ServeStatus.component.test.tsx
index 5436f583e02f..4ce4946c023c 100644
--- a/dashboard/client/src/common/ServeStatus.component.test.tsx
+++ b/dashboard/client/src/common/ServeStatus.component.test.tsx
@@ -1,60 +1,42 @@
import { render, screen } from "@testing-library/react";
import React from "react";
-import { ServeApplication, ServeApplicationStatus } from "../type/serve";
+import { ServeDeployment, ServeDeploymentStatus } from "../type/serve";
import { ServeStatusIcon } from "./ServeStatus";
-const APP: ServeApplication = {
- name: "MyServeApp",
- route_prefix: "/my-serve-app",
- docs_path: null,
- status: ServeApplicationStatus.RUNNING,
- message: "",
- last_deployed_time_s: 1682029771.0748637,
- deployed_app_config: null,
- deployments: {},
+const DEPLOYMENT: ServeDeployment = {
+ name: "MyServeDeployment",
+ deployment_config: {} as any,
+ message: "Running",
+ replicas: [],
+ status: ServeDeploymentStatus.HEALTHY,
};
describe("ServeStatusIcon", () => {
- it("renders RUNNING status", async () => {
- render();
+ it("renders HEALTHY status", async () => {
+ render();
- await screen.findByTestId("serve-status-icon");
-
- const icon = screen.getByTestId("serve-status-icon");
- const classList = icon.getAttribute("class");
- expect(classList).toContain("colorSuccess");
+ await screen.findByTitle("Healthy");
});
- it("renders NOT_STARTED status", async () => {
+ it("renders UNHEALTHY status", async () => {
render(
,
);
- await screen.findByTestId("serve-status-icon");
-
- expect(screen.queryByTestId("serve-status-icon")).not.toHaveClass(
- "colorSuccess",
- );
- expect(screen.queryByTestId("serve-status-icon")).not.toHaveClass(
- "colorError",
- );
+ await screen.findByTitle("Unhealthy");
});
- it("renders DEPLOY_FAILED status", async () => {
+ it("renders UPDATING status", async () => {
render(
,
);
- await screen.findByTestId("serve-status-icon");
-
- const icon = screen.getByTestId("serve-status-icon");
- const classList = icon.getAttribute("class");
- expect(classList).toContain("colorError");
+ await screen.findByTitle("Updating");
});
});
diff --git a/dashboard/client/src/common/ServeStatus.tsx b/dashboard/client/src/common/ServeStatus.tsx
index dd4ebad48889..b8fb9cc08537 100644
--- a/dashboard/client/src/common/ServeStatus.tsx
+++ b/dashboard/client/src/common/ServeStatus.tsx
@@ -1,17 +1,13 @@
import { createStyles, makeStyles } from "@material-ui/core";
import classNames from "classnames";
import React from "react";
-import {
- RiCloseCircleFill,
- RiRecordCircleFill,
- RiStopCircleFill,
-} from "react-icons/ri";
-import { ServeApplication } from "../type/serve";
+import { RiCloseCircleFill, RiRecordCircleFill } from "react-icons/ri";
+import { ServeDeployment } from "../type/serve";
import { JobRunningIcon } from "./JobStatus";
import { ClassNameProps } from "./props";
type ServeStatusIconProps = {
- app: ServeApplication;
+ deployment: ServeDeployment;
small: boolean;
} & ClassNameProps;
@@ -36,42 +32,31 @@ const useServeStatusIconStyles = makeStyles((theme) =>
);
export const ServeStatusIcon = ({
- app,
+ deployment,
small,
className,
}: ServeStatusIconProps) => {
const classes = useServeStatusIconStyles();
- switch (app.status) {
- case "RUNNING":
+ switch (deployment.status) {
+ case "HEALTHY":
return (
);
- case "NOT_STARTED":
- return (
-
- );
- case "DEPLOY_FAILED":
+ case "UNHEALTHY":
return (
);
default:
- // DEPLOYING || DELETEING
+ // UPDATING
return (
-
+
);
}
};
diff --git a/dashboard/client/src/common/props.d.ts b/dashboard/client/src/common/props.d.ts
index 593e8969081c..8c5d047e1279 100644
--- a/dashboard/client/src/common/props.d.ts
+++ b/dashboard/client/src/common/props.d.ts
@@ -1,3 +1,7 @@
export type ClassNameProps = {
className?: string;
};
+
+export type DataTestIdProps = {
+ "data-testid"?: string;
+};
diff --git a/dashboard/client/src/pages/layout/MainNavLayout.tsx b/dashboard/client/src/pages/layout/MainNavLayout.tsx
index 2e317fcc3547..75a955194b2a 100644
--- a/dashboard/client/src/pages/layout/MainNavLayout.tsx
+++ b/dashboard/client/src/pages/layout/MainNavLayout.tsx
@@ -143,7 +143,7 @@ const NAV_ITEMS = [
id: "jobs",
},
{
- title: "Serve",
+ title: "Serve Deployments",
path: "/serve",
id: "serve",
},
diff --git a/dashboard/client/src/pages/overview/cards/RecentServeCard.component.test.tsx b/dashboard/client/src/pages/overview/cards/RecentServeCard.component.test.tsx
index 1275c024a51d..fec3c043688d 100644
--- a/dashboard/client/src/pages/overview/cards/RecentServeCard.component.test.tsx
+++ b/dashboard/client/src/pages/overview/cards/RecentServeCard.component.test.tsx
@@ -28,6 +28,11 @@ describe("RecentServeCard", () => {
import_path: "home:graph",
},
last_deployed_time_s: new Date().getTime() / 1000,
+ deployments: {
+ FirstDeployment: {
+ name: "FirstDeployment",
+ },
+ },
},
"second-app": {
name: "second-app",
@@ -36,48 +41,52 @@ describe("RecentServeCard", () => {
status: ServeApplicationStatus.DEPLOYING,
deployed_app_config: null,
last_deployed_time_s: new Date().getTime() / 1000,
- deployments: {},
+ deployments: {
+ SecondDeployment: {
+ name: "SecondDeployment",
+ },
+ },
},
},
},
} as any);
});
- it("should display serve applications with deployed_app_config", async () => {
+ it("should display serve deployments with deployed_app_config", async () => {
render(, {
wrapper: TEST_APP_WRAPPER,
});
- await screen.findByText("View all applications");
+ await screen.findByText("View all deployments");
expect.assertions(3);
- expect(screen.getByText("home")).toBeInTheDocument();
+ expect(screen.getByText("FirstDeployment")).toBeInTheDocument();
expect(screen.getByText("home:graph")).toBeInTheDocument();
- expect(screen.getByText("Serve Applications")).toBeInTheDocument();
+ expect(screen.getByText("Serve Deployments")).toBeInTheDocument();
});
- it("should display serve applications without deployed_app_config", async () => {
+ it("should display serve deployments without deployed_app_config", async () => {
render(, {
wrapper: TEST_APP_WRAPPER,
});
- await screen.findByText("View all applications");
+ await screen.findByText("View all deployments");
expect.assertions(3);
- expect(screen.getByText("second-app")).toBeInTheDocument();
- expect(screen.getByText("-")).toBeInTheDocument(); // default value for no deployed_app_config
- expect(screen.getByText("Serve Applications")).toBeInTheDocument();
+ expect(screen.getByText("SecondDeployment")).toBeInTheDocument();
+ expect(screen.getByText("second-app")).toBeInTheDocument(); // default value for no deployed_app_config
+ expect(screen.getByText("Serve Deployments")).toBeInTheDocument();
});
- it("should navigate to the applications page when the 'View all applications' link is clicked", async () => {
+ it("should navigate to the applications page when the 'View all deployments' link is clicked", async () => {
render(, {
wrapper: TEST_APP_WRAPPER,
});
- await screen.findByText("View all applications");
+ await screen.findByText("View all deployments");
const link = screen.getByRole("link", {
- name: /view all applications/i,
+ name: /view all deployments/i,
});
- expect(link).toHaveAttribute("href");
+ expect(link).toHaveAttribute("href", "/serve");
});
});
diff --git a/dashboard/client/src/pages/overview/cards/RecentServeCard.tsx b/dashboard/client/src/pages/overview/cards/RecentServeCard.tsx
index 960cd8738006..c6089facb35d 100644
--- a/dashboard/client/src/pages/overview/cards/RecentServeCard.tsx
+++ b/dashboard/client/src/pages/overview/cards/RecentServeCard.tsx
@@ -3,7 +3,7 @@ import _ from "lodash";
import React from "react";
import { ServeStatusIcon } from "../../../common/ServeStatus";
import { ListItemCard } from "../../../components/ListItemCard";
-import { useServeApplications } from "../../serve/hook/useServeApplications";
+import { useServeDeployments } from "../../serve/hook/useServeApplications";
const useStyles = makeStyles((theme) =>
createStyles({
@@ -20,33 +20,45 @@ type RecentServeCardProps = {
export const RecentServeCard = ({ className }: RecentServeCardProps) => {
const classes = useStyles();
- // Use mock data by uncommenting the following line
- // const applications = mockServeApplications.applications;
- const { allServeApplications: applications } = useServeApplications();
+ const { allServeDeployments: deployments } = useServeDeployments();
- const sortedApplications = _.orderBy(
- applications,
- ["last_deployed_time_s"],
- ["desc"],
+ const sortedDeployments = _.orderBy(
+ deployments,
+ ["application.last_deployed_time_s", "name"],
+ ["desc", "asc"],
).slice(0, 6);
- const sortedApplicationsToRender = sortedApplications.map((app) => {
+ const sortedDeploymentsToRender = sortedDeployments.map((deployment) => {
return {
- title: app.name,
- subtitle: app?.deployed_app_config?.import_path || "-",
- link: app.name ? `/serve/applications/${app.name}` : undefined,
+ title: deployment.name,
+ subtitle:
+ deployment.application.deployed_app_config?.import_path ||
+ deployment.application.name ||
+ deployment.application.route_prefix,
+ link:
+ deployment.application.name && deployment.name
+ ? `/serve/applications/${encodeURIComponent(
+ deployment.application.name,
+ )}/${encodeURIComponent(deployment.name)}`
+ : undefined,
className: className,
- icon: ,
+ icon: (
+
+ ),
};
});
return (
);
diff --git a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
index 0533091c33a3..aee0f787297f 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.component.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor, within } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { useParams } from "react-router-dom";
@@ -22,7 +22,7 @@ const mockGetServeApplications = jest.mocked(getServeApplications);
describe("ServeApplicationDetailPage", () => {
it("renders page with deployments and replicas", async () => {
- expect.assertions(22);
+ expect.assertions(13);
mockUseParams.mockReturnValue({
applicationName: "home",
@@ -107,7 +107,7 @@ describe("ServeApplicationDetailPage", () => {
const user = userEvent.setup();
- await screen.findByText("home");
+ await screen.findAllByText("home");
expect(screen.getByTestId("metadata-content-for-Name")).toHaveTextContent(
"home",
);
@@ -141,28 +141,9 @@ describe("ServeApplicationDetailPage", () => {
expect(screen.getByText(/import_path: home:graph/)).toBeVisible();
// Config dialog for first deployment
- await user.click(screen.getAllByText("Deployment config")[0]);
+ await user.click(screen.getAllByText("View config")[0]);
await screen.findByText(/test-config: 1/);
expect(screen.getByText(/test-config: 1/)).toBeVisible();
expect(screen.getByText(/autoscaling-value: 2/)).toBeVisible();
-
- // All deployments are already expanded
- expect(screen.getByText("test-replica-1")).toBeVisible();
- expect(screen.getByText("test-replica-2")).toBeVisible();
- expect(screen.getByText("test-replica-3")).toBeVisible();
-
- // Collapse the first deployment
- 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.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 837b6f5256f2..787f5fe5c861 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeApplicationDetailPage.tsx
@@ -47,14 +47,15 @@ const useStyles = makeStyles((theme) =>
);
const columns: { label: string; helpInfo?: ReactElement; width?: string }[] = [
- { label: "" }, // For expand/collapse button
- { label: "Name" },
- { label: "Replicas" },
+ { label: "Deployment name" },
{ label: "Status" },
- { label: "Actions" },
{ label: "Status message", width: "30%" },
+ { label: "Num replicas" },
+ { label: "Actions" },
+ { label: "Application" },
+ { label: "Route prefix" },
{ label: "Last deployed at" },
- { label: "Duration" },
+ { label: "Duration (since last deploy)" },
];
export const ServeApplicationDetailPage = () => {
@@ -79,14 +80,6 @@ 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 (
@@ -176,7 +169,7 @@ export const ServeApplicationDetailPage = () => {
},
]}
/>
-
+
{
key={deployment.name}
deployment={deployment}
application={application}
- startExpanded={deploymentsStartExpanded}
/>
))}
@@ -292,7 +284,7 @@ export const ServeApplicationDetailLayout = () => {
id: "serveApplicationDetail",
title: appName,
pageTitle: `${appName} | Serve Application`,
- path: `/serve/applications/${appName}`,
+ path: `/serve/applications/${encodeURIComponent(appName)}`,
}}
/>
diff --git a/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.component.test.tsx b/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.component.test.tsx
new file mode 100644
index 000000000000..17f089224974
--- /dev/null
+++ b/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.component.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import { useParams } from "react-router-dom";
+import { getServeApplications } from "../../service/serve";
+import {
+ ServeApplicationStatus,
+ ServeDeploymentStatus,
+ ServeReplicaState,
+} from "../../type/serve";
+import { TEST_APP_WRAPPER } from "../../util/test-utils";
+import { ServeDeploymentDetailPage } from "./ServeDeploymentDetailPage";
+
+jest.mock("react-router-dom", () => ({
+ ...jest.requireActual("react-router-dom"),
+ useParams: jest.fn(),
+}));
+jest.mock("../../service/serve");
+
+const mockUseParams = jest.mocked(useParams);
+const mockGetServeApplications = jest.mocked(getServeApplications);
+
+describe("ServeDeploymentDetailPage", () => {
+ it("renders page with deployment details", async () => {
+ expect.assertions(9);
+
+ mockUseParams.mockReturnValue({
+ applicationName: "home",
+ deploymentName: "FirstDeployment",
+ });
+ mockGetServeApplications.mockResolvedValue({
+ data: {
+ applications: {
+ home: {
+ name: "home",
+ route_prefix: "/home",
+ message: null,
+ status: ServeApplicationStatus.RUNNING,
+ deployed_app_config: {
+ import_path: "home:graph",
+ },
+ last_deployed_time_s: new Date().getTime() / 1000,
+ deployments: {
+ FirstDeployment: {
+ name: "FirstDeployment",
+ deployment_config: {
+ "test-config": 1,
+ autoscaling_config: {
+ "autoscaling-value": 2,
+ },
+ },
+ status: ServeDeploymentStatus.HEALTHY,
+ message: "deployment is healthy",
+ replicas: [
+ {
+ actor_id: "test-actor-id",
+ actor_name: "FirstDeployment",
+ node_id: "test-node-id",
+ node_ip: "123.456.789.123",
+ pid: "12345",
+ replica_id: "test-replica-1",
+ start_time_s: new Date().getTime() / 1000,
+ state: ServeReplicaState.STARTING,
+ },
+ {
+ actor_id: "test-actor-id-2",
+ actor_name: "FirstDeployment",
+ node_id: "test-node-id",
+ node_ip: "123.456.789.123",
+ pid: "12346",
+ replica_id: "test-replica-2",
+ start_time_s: new Date().getTime() / 1000,
+ state: ServeReplicaState.RUNNING,
+ },
+ ],
+ },
+ SecondDeployment: {
+ name: "SecondDeployment",
+ deployment_config: {},
+ status: ServeDeploymentStatus.UPDATING,
+ message: "deployment is updating",
+ replicas: [
+ {
+ actor_id: "test-actor-id-3",
+ actor_name: "SecondDeployment",
+ node_id: "test-node-id",
+ node_ip: "123.456.789.123",
+ pid: "12347",
+ replica_id: "test-replica-3",
+ start_time_s: new Date().getTime() / 1000,
+ state: ServeReplicaState.STARTING,
+ },
+ ],
+ },
+ },
+ },
+ "second-app": {
+ // Decoy second app
+ },
+ "third-app": {
+ // Decoy third app
+ },
+ },
+ },
+ } as any);
+
+ render(, { wrapper: TEST_APP_WRAPPER });
+
+ const user = userEvent.setup();
+
+ await screen.findByText("FirstDeployment");
+ expect(screen.getByTestId("metadata-content-for-Name")).toHaveTextContent(
+ "FirstDeployment",
+ );
+ expect(screen.getByTestId("metadata-content-for-Status")).toHaveTextContent(
+ "HEALTHY",
+ );
+ expect(
+ screen.getByTestId("metadata-content-for-Replicas"),
+ ).toHaveTextContent("2");
+
+ // First replica
+ expect(screen.getByText("test-replica-1")).toBeVisible();
+ expect(screen.getByText("STARTING")).toBeVisible();
+
+ // Second replica
+ expect(screen.getByText("test-replica-2")).toBeVisible();
+ expect(screen.getByText("RUNNING")).toBeVisible();
+
+ // Config dialog for deployment
+ await user.click(
+ within(
+ screen.getByTestId("metadata-content-for-Deployment config"),
+ ).getByText("View"),
+ );
+ await screen.findByText(/test-config: 1/);
+ expect(screen.getByText(/test-config: 1/)).toBeVisible();
+ expect(screen.getByText(/autoscaling-value: 2/)).toBeVisible();
+ });
+});
diff --git a/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.tsx b/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.tsx
new file mode 100644
index 000000000000..5479aa30ea7c
--- /dev/null
+++ b/dashboard/client/src/pages/serve/ServeDeploymentDetailPage.tsx
@@ -0,0 +1,293 @@
+import {
+ Box,
+ createStyles,
+ InputAdornment,
+ makeStyles,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ TextFieldProps,
+ Typography,
+} from "@material-ui/core";
+import { Autocomplete, Pagination } from "@material-ui/lab";
+import React, { ReactElement } from "react";
+import { Outlet, useParams } from "react-router-dom";
+import { CodeDialogButton } from "../../common/CodeDialogButton";
+import { CollapsibleSection } from "../../common/CollapsibleSection";
+import { DurationText } from "../../common/DurationText";
+import { formatDateFromTimeMs } from "../../common/formatUtils";
+import Loading from "../../components/Loading";
+import { MetadataSection } from "../../components/MetadataSection";
+import { StatusChip } from "../../components/StatusChip";
+import { HelpInfo } from "../../components/Tooltip";
+import { MainNavPageInfo } from "../layout/mainNavContext";
+import { useServeDeploymentDetails } from "./hook/useServeApplications";
+import { ServeReplicaRow } from "./ServeDeploymentRow";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ padding: theme.spacing(3),
+ },
+ table: {
+ tableLayout: "fixed",
+ },
+ helpInfo: {
+ marginLeft: theme.spacing(1),
+ },
+ }),
+);
+
+const columns: { label: string; helpInfo?: ReactElement; width?: string }[] = [
+ { label: "Replica ID" },
+ { label: "Status" },
+ { label: "Actions" },
+ { label: "Started at" },
+ { label: "Duration" },
+];
+
+export const ServeDeploymentDetailPage = () => {
+ const classes = useStyles();
+ const { applicationName, deploymentName } = useParams();
+
+ const {
+ application,
+ deployment,
+ filteredReplicas,
+ page,
+ setPage,
+ changeFilter,
+ } = useServeDeploymentDetails(applicationName, deploymentName);
+
+ if (!application) {
+ return (
+
+ Application with name "{applicationName}" not found.
+
+ );
+ }
+
+ if (!deployment) {
+ return (
+
+ Deployment with name "{deploymentName}" not found.
+
+ );
+ }
+
+ return (
+
+
+ {" "}
+ {deployment.message && (
+
+ )}
+
+ ),
+ },
+ {
+ label: "Name",
+ content: {
+ value: deployment.name,
+ },
+ },
+ {
+ label: "Replicas",
+ content: {
+ value: `${Object.keys(deployment.replicas).length}`,
+ },
+ },
+ {
+ label: "Deployment config",
+ content: deployment.deployment_config ? (
+
+ ) : (
+ -
+ ),
+ },
+ {
+ label: "Last deployed at",
+ content: {
+ value: formatDateFromTimeMs(
+ application.last_deployed_time_s * 1000,
+ ),
+ },
+ },
+ {
+ label: "Duration (since last deploy)",
+ content: (
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
e.replica_id)),
+ )}
+ onInputChange={(_: any, value: string) => {
+ changeFilter(
+ "replica_id",
+ value.trim() !== "-" ? value.trim() : "",
+ );
+ }}
+ renderInput={(params: TextFieldProps) => (
+
+ )}
+ />
+ e.state)),
+ )}
+ onInputChange={(_: any, value: string) => {
+ changeFilter("state", value.trim());
+ }}
+ renderInput={(params: TextFieldProps) => (
+
+ )}
+ />
+ {
+ setPage("pageSize", Math.min(Number(value), 500) || 10);
+ },
+ endAdornment: (
+ Per Page
+ ),
+ }}
+ />
+
+
+
setPage("pageNo", pageNo)}
+ />
+
+
+
+
+ {columns.map(({ label, helpInfo, width }) => (
+
+
+ {label}
+ {helpInfo && (
+
+ {helpInfo}
+
+ )}
+
+
+ ))}
+
+
+
+ {filteredReplicas
+ .slice(
+ (page.pageNo - 1) * page.pageSize,
+ page.pageNo * page.pageSize,
+ )
+ .map((replica) => (
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export const ServeDeploymentDetailLayout = () => {
+ const { applicationName, deploymentName } = useParams();
+
+ const { application, deployment, loading, error } = useServeDeploymentDetails(
+ applicationName,
+ deploymentName,
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return {error.message};
+ }
+
+ if (!application) {
+ return (
+
+ Application with name "{applicationName}" not found.
+
+ );
+ }
+
+ if (!deployment) {
+ return (
+
+ Deployment with name "{deploymentName}" not found.
+
+ );
+ }
+
+ const appName = application.name ? application.name : "-";
+
+ return (
+
+
+
+
+ );
+};
diff --git a/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx b/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
index 347b822dcdcb..6b395ce31e4b 100644
--- a/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
+++ b/dashboard/client/src/pages/serve/ServeDeploymentRow.tsx
@@ -1,13 +1,11 @@
import {
createStyles,
- IconButton,
Link,
makeStyles,
TableCell,
TableRow,
} from "@material-ui/core";
-import React, { useState } from "react";
-import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
+import React from "react";
import { Link as RouterLink } from "react-router-dom";
import {
CodeDialogButton,
@@ -43,57 +41,61 @@ const useStyles = makeStyles((theme) =>
export type ServeDeployentRowProps = {
deployment: ServeDeployment;
application: ServeApplication;
- startExpanded?: boolean;
};
export const ServeDeploymentRow = ({
deployment,
- application: { last_deployed_time_s },
- startExpanded = false,
+ application: { last_deployed_time_s, name: applicationName, route_prefix },
}: ServeDeployentRowProps) => {
const { name, status, message, deployment_config, replicas } = deployment;
const classes = useStyles();
- const [expanded, setExpanded] = useState(startExpanded);
const metricsUrl = useViewServeDeploymentMetricsButtonUrl(name);
return (
-
- {
- setExpanded(!expanded);
- }}
- >
- {!expanded ? (
-
- ) : (
-
- )}
-
-
- {name}
+
+ {name}
+
- {Object.keys(replicas).length}
+
+ {message ? (
+
+ ) : (
+ "-"
+ )}
+
+ {replicas.length}
+
+
+ Logs
+
{metricsUrl && (
@@ -104,16 +106,14 @@ export const ServeDeploymentRow = ({
)}
- {message ? (
-
- ) : (
- "-"
- )}
+
+ {applicationName}
+
+ {route_prefix}
{formatDateFromTimeMs(last_deployed_time_s * 1000)}
@@ -121,14 +121,6 @@ export const ServeDeploymentRow = ({
- {expanded &&
- replicas.map((replica) => (
-
- ))}
);
};
@@ -148,24 +140,16 @@ export const ServeReplicaRow = ({
return (
-
-
+
{replica_id}
- -
-
+
Log
{metricsUrl && (
@@ -177,7 +161,6 @@ export const ServeReplicaRow = ({
)}
- -
{formatDateFromTimeMs(start_time_s * 1000)}
diff --git a/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx b/dashboard/client/src/pages/serve/ServeDeploymentsListPage.component.test.tsx
similarity index 69%
rename from dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx
rename to dashboard/client/src/pages/serve/ServeDeploymentsListPage.component.test.tsx
index bf225a61c19f..e2eb5fec3d6f 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationsListPage.component.test.tsx
+++ b/dashboard/client/src/pages/serve/ServeDeploymentsListPage.component.test.tsx
@@ -8,7 +8,7 @@ import {
ServeSystemActorStatus,
} from "../../type/serve";
import { TEST_APP_WRAPPER } from "../../util/test-utils";
-import { ServeApplicationsListPage } from "./ServeApplicationsListPage";
+import { ServeDeploymentsListPage } from "./ServeDeploymentsListPage";
jest.mock("../../service/actor");
jest.mock("../../service/serve");
@@ -16,9 +16,9 @@ jest.mock("../../service/serve");
const mockGetServeApplications = jest.mocked(getServeApplications);
const mockGetActor = jest.mocked(getActor);
-describe("ServeApplicationsListPage", () => {
+describe("ServeDeploymentsListPage", () => {
it("renders list", async () => {
- expect.assertions(5);
+ expect.assertions(6);
// Mock ServeController actor fetch
mockGetActor.mockResolvedValue({
@@ -57,8 +57,14 @@ describe("ServeApplicationsListPage", () => {
},
last_deployed_time_s: new Date().getTime() / 1000,
deployments: {
- FirstDeployment: {},
- SecondDeployment: {},
+ FirstDeployment: {
+ name: "FirstDeployment",
+ replicas: [],
+ },
+ SecondDeployment: {
+ name: "SecondDeployment",
+ replicas: [],
+ },
},
},
"second-app": {
@@ -71,24 +77,29 @@ describe("ServeApplicationsListPage", () => {
},
last_deployed_time_s: new Date().getTime() / 1000,
deployments: {
- ThirdDeployment: {},
+ ThirdDeployment: {
+ name: "ThirdDeployment",
+ replicas: [],
+ },
},
},
},
},
} as any);
- render(, { wrapper: TEST_APP_WRAPPER });
- await screen.findByText("Application status");
+ render(, { wrapper: TEST_APP_WRAPPER });
+ await screen.findByText("Deployments status");
// First row
- expect(screen.getByText("home")).toBeVisible();
- expect(screen.getByText("/")).toBeVisible();
+ expect(screen.getByText("FirstDeployment")).toBeVisible();
+ expect(screen.getAllByText("/")[0]).toBeVisible();
// Second row
- expect(screen.getByText("second-app")).toBeVisible();
- expect(screen.getByText("/second-app")).toBeVisible();
+ expect(screen.getByText("SecondDeployment")).toBeVisible();
+ expect(screen.getAllByText("/")[1]).toBeVisible();
- expect(screen.getByText("Metrics")).toBeVisible();
+ // Third row
+ expect(screen.getByText("ThirdDeployment")).toBeVisible();
+ expect(screen.getByText("/second-app")).toBeVisible();
});
});
diff --git a/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx b/dashboard/client/src/pages/serve/ServeDeploymentsListPage.tsx
similarity index 82%
rename from dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx
rename to dashboard/client/src/pages/serve/ServeDeploymentsListPage.tsx
index eb7431194695..20b105ecfba2 100644
--- a/dashboard/client/src/pages/serve/ServeApplicationsListPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeDeploymentsListPage.tsx
@@ -25,8 +25,8 @@ 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 { useServeDeployments } from "./hook/useServeApplications";
+import { ServeDeploymentRow } from "./ServeDeploymentRow";
import { ServeMetricsSection } from "./ServeMetricsSection";
import { ServeSystemPreview } from "./ServeSystemDetails";
@@ -44,7 +44,7 @@ const useStyles = makeStyles((theme) =>
helpInfo: {
marginLeft: theme.spacing(1),
},
- applicationsSection: {
+ deploymentsSection: {
marginTop: theme.spacing(4),
},
section: {
@@ -54,28 +54,29 @@ const useStyles = makeStyles((theme) =>
);
const columns: { label: string; helpInfo?: ReactElement; width?: string }[] = [
- { label: "Application name" },
- { label: "Route prefix" },
+ { label: "Deployment name" },
{ label: "Status" },
{ label: "Status message", width: "30%" },
- { label: "Num deployments" },
+ { label: "Num replicas" },
+ { label: "Actions" },
+ { label: "Application" },
+ { label: "Route prefix" },
{ label: "Last deployed at" },
{ label: "Duration (since last deploy)" },
- { label: "Application config" },
];
-export const ServeApplicationsListPage = () => {
+export const ServeDeploymentsListPage = () => {
const classes = useStyles();
const {
serveDetails,
- filteredServeApplications,
+ filteredServeDeployments,
error,
- allServeApplications,
+ allServeDeployments,
page,
setPage,
proxies,
changeFilter,
- } = useServeApplications();
+ } = useServeDeployments();
if (error) {
return {error.toString()};
@@ -94,14 +95,14 @@ export const ServeApplicationsListPage = () => {
) : (
@@ -109,7 +110,7 @@ export const ServeApplicationsListPage = () => {
style={{ margin: 8, width: 120 }}
options={Array.from(
new Set(
- allServeApplications.map((e) => (e.name ? e.name : "-")),
+ allServeDeployments.map((e) => (e.name ? e.name : "-")),
),
)}
onInputChange={(_: any, value: string) => {
@@ -125,7 +126,7 @@ export const ServeApplicationsListPage = () => {
e.status)),
+ new Set(allServeDeployments.map((e) => e.status)),
)}
onInputChange={(_: any, value: string) => {
changeFilter("status", value.trim());
@@ -134,6 +135,18 @@ export const ServeApplicationsListPage = () => {
)}
/>
+ e.applicationName)),
+ )}
+ onInputChange={(_: any, value: string) => {
+ changeFilter("applicationName", value.trim());
+ }}
+ renderInput={(params: TextFieldProps) => (
+
+ )}
+ />
{
setPage("pageNo", pageNo)}
@@ -184,15 +197,16 @@ export const ServeApplicationsListPage = () => {
- {filteredServeApplications
+ {filteredServeDeployments
.slice(
(page.pageNo - 1) * page.pageSize,
page.pageNo * page.pageSize,
)
- .map((application) => (
- (
+
))}
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx b/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx
index 81b3171ced2d..cab3b5b1484a 100644
--- a/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx
+++ b/dashboard/client/src/pages/serve/ServeSystemDetailPage.tsx
@@ -4,7 +4,7 @@ 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 { useServeDeployments } from "./hook/useServeApplications";
import { ServeSystemDetails } from "./ServeSystemDetails";
const useStyles = makeStyles((theme) =>
@@ -22,7 +22,7 @@ export const ServeSystemDetailPage = () => {
const classes = useStyles();
const { serveDetails, proxies, proxiesPage, setProxiesPage, error } =
- useServeApplications();
+ useServeDeployments();
if (error) {
return {error.toString()};
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx b/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx
index e500ccf40d74..e87fb6c263b5 100644
--- a/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx
+++ b/dashboard/client/src/pages/serve/ServeSystemDetails.component.test.tsx
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import React from "react";
import { ActorEnum } from "../../type/actor";
import {
- ServeApplicationStatus,
+ ServeDeploymentStatus,
ServeSystemActorStatus,
} from "../../type/serve";
import { TEST_APP_WRAPPER } from "../../util/test-utils";
@@ -14,7 +14,7 @@ const mockedUseFetchActor = jest.mocked(useFetchActor);
describe("ServeSystemDetails", () => {
it("renders", async () => {
- expect.assertions(7);
+ expect.assertions(6);
mockedUseFetchActor.mockReturnValue({
data: {
@@ -24,16 +24,16 @@ describe("ServeSystemDetails", () => {
render(
{
{ wrapper: TEST_APP_WRAPPER },
);
await screen.findByText("STARTING");
- // Controller and Proxy
- expect(screen.getAllByText("HEALTHY")).toHaveLength(2);
+ // Controller, Proxy, and Deployment
+ expect(screen.getAllByText("HEALTHY")).toHaveLength(3);
expect(screen.getByText("STARTING")).toBeInTheDocument();
- // Applications
- expect(screen.getByText("RUNNING")).toBeInTheDocument();
- expect(screen.getByText("DEPLOYING")).toBeInTheDocument();
+ // Other deployments
+ expect(screen.getByText("UPDATING")).toBeInTheDocument();
expect(screen.getByText("UNHEALTHY")).toBeInTheDocument();
expect(
diff --git a/dashboard/client/src/pages/serve/ServeSystemDetails.tsx b/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
index 8a64ca90cb6c..0e0cc8674d37 100644
--- a/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
+++ b/dashboard/client/src/pages/serve/ServeSystemDetails.tsx
@@ -18,8 +18,8 @@ import { MetadataSection } from "../../components/MetadataSection";
import { StatusChip, StatusChipProps } from "../../components/StatusChip";
import { HelpInfo } from "../../components/Tooltip";
import {
- ServeApplication,
ServeApplicationsRsp,
+ ServeDeployment,
ServeProxy,
} from "../../type/serve";
import { useFetchActor } from "../actor/hook/useActorDetail";
@@ -158,13 +158,13 @@ export const ServeSystemDetails = ({
type ServeSystemPreviewProps = {
serveDetails: ServeDetails;
proxies: ServeProxy[];
- allApplications: ServeApplication[];
+ allDeployments: ServeDeployment[];
};
export const ServeSystemPreview = ({
serveDetails,
proxies,
- allApplications,
+ allDeployments,
}: ServeSystemPreviewProps) => {
const { data: controllerActor } = useFetchActor(
serveDetails.controller_info.actor_id,
@@ -200,12 +200,12 @@ export const ServeSystemPreview = ({
),
},
{
- label: "Application status",
+ label: "Deployments status",
content: (
),
},
diff --git a/dashboard/client/src/pages/serve/hook/useServeApplications.ts b/dashboard/client/src/pages/serve/hook/useServeApplications.ts
index 3b9847eb93dd..b8c110b11855 100644
--- a/dashboard/client/src/pages/serve/hook/useServeApplications.ts
+++ b/dashboard/client/src/pages/serve/hook/useServeApplications.ts
@@ -12,15 +12,18 @@ const SERVE_PROXY_STATUS_SORT_ORDER: Record = {
[ServeSystemActorStatus.DRAINING]: 3,
};
-export const useServeApplications = () => {
+export const useServeDeployments = () => {
const [page, setPage] = useState({ pageSize: 10, pageNo: 1 });
const [filter, setFilter] = useState<
{
- key: "name" | "status";
+ key: "name" | "status" | "applicationName";
val: string;
}[]
>([]);
- const changeFilter = (key: "name" | "status", val: string) => {
+ const changeFilter = (
+ key: "name" | "status" | "applicationName",
+ val: string,
+ ) => {
const f = filter.find((e) => e.key === key);
if (f) {
f.val = val;
@@ -36,12 +39,26 @@ export const useServeApplications = () => {
});
const { data, error } = useSWR(
- "useServeApplications",
+ "useServeDeployments",
async () => {
const rsp = await getServeApplications();
if (rsp) {
- return rsp.data;
+ const serveApplicationsList = rsp.data
+ ? Object.values(rsp.data.applications).sort(
+ (a, b) =>
+ (b.last_deployed_time_s ?? 0) - (a.last_deployed_time_s ?? 0),
+ )
+ : [];
+
+ const serveDeploymentsList = serveApplicationsList.flatMap((app) =>
+ Object.values(app.deployments).map((d) => ({
+ ...d,
+ applicationName: app.name,
+ application: app,
+ })),
+ );
+ return { ...rsp.data, serveDeploymentsList };
}
},
{ refreshInterval: API_REFRESH_INTERVAL_MS },
@@ -55,11 +72,6 @@ export const useServeApplications = () => {
controller_info: data.controller_info,
}
: undefined;
- const serveApplicationsList = data
- ? Object.values(data.applications).sort(
- (a, b) => (b.last_deployed_time_s ?? 0) - (a.last_deployed_time_s ?? 0),
- )
- : [];
const proxies =
data && data.proxies
@@ -70,9 +82,11 @@ export const useServeApplications = () => {
)
: [];
+ const serveDeploymentsList = data?.serveDeploymentsList ?? [];
+
return {
serveDetails,
- filteredServeApplications: serveApplicationsList.filter((app) =>
+ filteredServeDeployments: serveDeploymentsList.filter((app) =>
filter.every((f) =>
f.val ? app[f.key] && (app[f.key] ?? "").includes(f.val) : true,
),
@@ -85,7 +99,7 @@ export const useServeApplications = () => {
proxiesPage,
setProxiesPage: (key: string, val: number) =>
setProxiesPage({ ...proxiesPage, [key]: val }),
- allServeApplications: serveApplicationsList,
+ allServeDeployments: serveDeploymentsList,
};
};
@@ -151,6 +165,67 @@ export const useServeApplicationDetails = (
};
};
+export const useServeDeploymentDetails = (
+ applicationName: string | undefined,
+ deploymentName: string | undefined,
+) => {
+ const [page, setPage] = useState({ pageSize: 10, pageNo: 1 });
+ const [filter, setFilter] = useState<
+ {
+ key: "replica_id" | "state";
+ val: string;
+ }[]
+ >([]);
+ const changeFilter = (key: "replica_id" | "state", val: string) => {
+ const f = filter.find((e) => e.key === key);
+ if (f) {
+ f.val = val;
+ } else {
+ filter.push({ key, val });
+ }
+ setFilter([...filter]);
+ };
+
+ // TODO(aguo): Use a fetch by deploymentId endpoint?
+ const { data, error } = useSWR(
+ "useServeApplications",
+ async () => {
+ const rsp = await getServeApplications();
+
+ if (rsp) {
+ return rsp.data;
+ }
+ },
+ { refreshInterval: API_REFRESH_INTERVAL_MS },
+ );
+
+ const application = applicationName
+ ? data?.applications?.[applicationName !== "-" ? applicationName : ""]
+ : undefined;
+ const deployment = deploymentName
+ ? application?.deployments[deploymentName]
+ : undefined;
+
+ const replicas = deployment?.replicas ?? [];
+
+ // Need to expose loading because it's not clear if undefined values
+ // for application, deployment, or replica means loading or missing data.
+ return {
+ loading: !data && !error,
+ application,
+ deployment,
+ filteredReplicas: replicas.filter((replica) =>
+ filter.every((f) =>
+ f.val ? replica[f.key] && (replica[f.key] ?? "").includes(f.val) : true,
+ ),
+ ),
+ changeFilter,
+ page,
+ setPage: (key: string, val: number) => setPage({ ...page, [key]: val }),
+ error,
+ };
+};
+
export const useServeReplicaDetails = (
applicationName: string | undefined,
deploymentName: string | undefined,
@@ -158,7 +233,7 @@ export const useServeReplicaDetails = (
) => {
// TODO(aguo): Use a fetch by replicaId endpoint?
const { data, error } = useSWR(
- "useServeReplicaDetails",
+ "useServeApplications",
async () => {
const rsp = await getServeApplications();
@@ -192,7 +267,7 @@ export const useServeReplicaDetails = (
export const useServeProxyDetails = (proxyId: string | undefined) => {
const { data, error, isLoading } = useSWR(
- "useServeProxyDetails",
+ "useServeApplications",
async () => {
const rsp = await getServeApplications();
@@ -216,7 +291,7 @@ export const useServeProxyDetails = (proxyId: string | undefined) => {
export const useServeControllerDetails = () => {
const { data, error, isLoading } = useSWR(
- "useServeControllerDetails",
+ "useServeApplications",
async () => {
const rsp = await getServeApplications();