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();