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 (
+ {footer}
); }; 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}`}} + /> + ))} + ); };