diff --git a/dashboard/client/src/App.tsx b/dashboard/client/src/App.tsx index a08a26200f2b..a60571201bcc 100644 --- a/dashboard/client/src/App.tsx +++ b/dashboard/client/src/App.tsx @@ -4,7 +4,7 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import React, { Suspense, useEffect, useState } from "react"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; -import ActorDetailPage from "./pages/actor/ActorDetail"; +import ActorDetailPage, { ActorDetailLayout } from "./pages/actor/ActorDetail"; import { ActorLayout } from "./pages/actor/ActorLayout"; import Loading from "./pages/exception/Loading"; import JobList, { JobsLayout } from "./pages/job"; @@ -33,6 +33,7 @@ import { ServeApplicationsListPage } from "./pages/serve/ServeApplicationsListPa import { ServeLayout } from "./pages/serve/ServeLayout"; import { ServeReplicaDetailPage } from "./pages/serve/ServeReplicaDetailPage"; import { ServeHttpProxyDetailPage } from "./pages/serve/ServeSystemActorDetailPage"; +import { TaskPage } from "./pages/task/TaskPage"; import { getNodeList } from "./service/node"; import { lightTheme } from "./theme"; @@ -201,16 +202,23 @@ const App = () => { - + } path="actors/:actorId" - /> + > + } path="" /> + } path="tasks/:taskId" /> + + } path="tasks/:taskId" /> } path="actors"> } path="" /> - } path=":actorId" /> + } path=":actorId"> + } path="" /> + } path="tasks/:taskId" /> + } path="metrics" /> } path="serve"> diff --git a/dashboard/client/src/common/MultiTabLogViewer.tsx b/dashboard/client/src/common/MultiTabLogViewer.tsx index 19b26b7a2226..e9a9d617cc60 100644 --- a/dashboard/client/src/common/MultiTabLogViewer.tsx +++ b/dashboard/client/src/common/MultiTabLogViewer.tsx @@ -25,9 +25,7 @@ const useStyles = makeStyles((theme) => export type MultiTabLogViewerTabDetails = { title: string; - nodeId: string | null; - filename?: string; -}; +} & LogViewerData; export type MultiTabLogViewerProps = { tabs: MultiTabLogViewerTabDetails[]; @@ -45,6 +43,10 @@ export const MultiTabLogViewer = ({ const currentTab = tabs.find((tab) => tab.title === value); + if (tabs.length === 0) { + return No logs to display.; + } + return (
- { - setValue(newValue); - }} - indicatorColor="primary" - > - {tabs.map(({ title }) => ( - - ))} - {otherLogsLink && ( - - Other logs   - - } - onClick={(event) => { - // Prevent the tab from changing - setValue(value); - }} - component={Link} - to={otherLogsLink} - target="_blank" - rel="noopener noreferrer" - /> - )} - + {(tabs.length > 1 || otherLogsLink) && ( + { + setValue(newValue); + }} + indicatorColor="primary" + > + {tabs.map(({ title }) => ( + + ))} + {otherLogsLink && ( + + Other logs   + + } + onClick={(event) => { + // Prevent the tab from changing + setValue(value); + }} + component={Link} + to={otherLogsLink} + target="_blank" + rel="noopener noreferrer" + /> + )} + + )} {!currentTab ? ( Please select a tab. ) : ( - tabs.map(({ title, nodeId, filename }) => ( - - - - )) + tabs.map((tab) => { + const { title, ...data } = tab; + return ( + + + + ); + }) )} + "contents" in data; + +const isLogViewerDataActor = (data: LogViewerData): data is ActorData => + "actorId" in data; + +const isLogViewerDataTask = (data: LogViewerData): data is TaskData => + "taskId" in data; + +export type StateApiLogViewerProps = { height?: number; + data: LogViewerData; }; export const StateApiLogViewer = ({ + height = 300, + data, +}: StateApiLogViewerProps) => { + if (isLogViewerDataText(data)) { + return ; + } else if (isLogViewerDataActor(data)) { + return ; + } else if (isLogViewerDataTask(data)) { + return ; + } else { + return ; + } +}; + +const TextLogViewer = ({ + height = 300, + contents, +}: { + height: number; + contents: string; +}) => { + return ; +}; + +const FileLogViewer = ({ + height = 300, nodeId, filename, +}: { + height: number; +} & FileData) => { + const apiData = useStateApiLogs({ nodeId, filename }, filename); + return ; +}; + +const ActorLogViewer = ({ height = 300, -}: StateApiLogViewerProps) => { - const { downloadUrl, log, path, refresh } = useStateApiLogs(nodeId, filename); + actorId, + suffix, +}: { + height: number; +} & ActorData) => { + const apiData = useStateApiLogs( + { actorId, suffix }, + `actor-log-${actorId}.${suffix}`, + ); + return ; +}; + +const TaskLogViewer = ({ + height = 300, + taskId, + suffix, +}: { + height: number; +} & TaskData) => { + const apiData = useStateApiLogs( + { taskId, suffix }, + `task-log-${taskId}.${suffix}`, + ); + return ; +}; + +const ApiLogViewer = ({ + apiData: { downloadUrl, log, path, refresh }, + height = 300, +}: { + apiData: ReturnType; + height: number; +}) => { return typeof log === "string" ? ( { refresh(); diff --git a/dashboard/client/src/components/AutoscalerStatusCards.tsx b/dashboard/client/src/components/AutoscalerStatusCards.tsx index 887c192ae36b..eb00be02621d 100644 --- a/dashboard/client/src/components/AutoscalerStatusCards.tsx +++ b/dashboard/client/src/components/AutoscalerStatusCards.tsx @@ -31,7 +31,7 @@ const formatClusterStatus = (title: string, cluster_status: string) => { return (
- {title} + {title} {cluster_status_rows.map((i, key) => { // Format the output. @@ -64,9 +64,6 @@ export const NodeStatusCard = ({ cluster_status }: StatusCardProps) => { overflow: "hidden", overflowY: "scroll", }} - sx={{ borderRadius: "16px" }} - marginLeft={1} - marginRight={1} > {cluster_status?.data ? formatNodeStatus(cluster_status?.data.clusterStatus) @@ -82,9 +79,6 @@ export const ResourceStatusCard = ({ cluster_status }: StatusCardProps) => { overflow: "hidden", overflowY: "scroll", }} - sx={{ border: 1, borderRadius: "1", borderColor: "primary.main" }} - marginLeft={1} - marginRight={1} > {cluster_status?.data ? formatResourcesStatus(cluster_status?.data.clusterStatus) diff --git a/dashboard/client/src/components/MetadataSection/MetadataSection.tsx b/dashboard/client/src/components/MetadataSection/MetadataSection.tsx index 91c73dc7723d..f9091a78a62e 100644 --- a/dashboard/client/src/components/MetadataSection/MetadataSection.tsx +++ b/dashboard/client/src/components/MetadataSection/MetadataSection.tsx @@ -30,6 +30,9 @@ type CopyableMetadataContent = StringOnlyMetadataContent & { readonly copyableValue: string; }; +type CopyAndLinkableMetadataContent = LinkableMetadataContent & + CopyableMetadataContent; + export type Metadata = { readonly label: string; readonly labelTooltip?: string | JSX.Element; @@ -39,6 +42,7 @@ export type Metadata = { | StringOnlyMetadataContent | LinkableMetadataContent | CopyableMetadataContent + | CopyAndLinkableMetadataContent | JSX.Element; /** @@ -92,6 +96,28 @@ export const MetadataContentField: React.FC<{ const classes = useStyles(); const [copyIconClicked, setCopyIconClicked] = useState(false); + const copyElement = content && "copyableValue" in content && ( + + { + setCopyIconClicked(true); + copy(content.copyableValue); + }} + // Set up mouse events to avoid text changing while tooltip is visible + onMouseEnter={() => setCopyIconClicked(false)} + onMouseLeave={() => setTimeout(() => setCopyIconClicked(false), 333)} + size="small" + className={classes.button} + > + + + + ); + if (content === undefined || "value" in content) { return content === undefined || !("link" in content) ? (
@@ -103,47 +129,31 @@ export const MetadataContentField: React.FC<{ > {content?.value ?? "-"} - {content && "copyableValue" in content && ( - - { - setCopyIconClicked(true); - copy(content.copyableValue); - }} - // Set up mouse events to avoid text changing while tooltip is visible - onMouseEnter={() => setCopyIconClicked(false)} - onMouseLeave={() => - setTimeout(() => setCopyIconClicked(false), 333) - } - size="small" - className={classes.button} - > - - - - )} + {copyElement}
) : content.link.startsWith("http") ? ( - - {content.value} - +
+ + {content.value} + + {copyElement} +
) : ( - - {content.value} - +
+ + {content.value} + + {copyElement} +
); } return
{content}
; diff --git a/dashboard/client/src/components/TaskTable.tsx b/dashboard/client/src/components/TaskTable.tsx index a5343b1be211..fbce5c5a2683 100644 --- a/dashboard/client/src/components/TaskTable.tsx +++ b/dashboard/client/src/components/TaskTable.tsx @@ -1,8 +1,7 @@ import { Box, - createStyles, InputAdornment, - makeStyles, + Link, Table, TableBody, TableCell, @@ -15,10 +14,9 @@ import { } from "@material-ui/core"; import Autocomplete from "@material-ui/lab/Autocomplete"; import Pagination from "@material-ui/lab/Pagination"; -import React, { useContext, useState } from "react"; -import { Link } from "react-router-dom"; -import { GlobalContext } from "../App"; -import DialogWithTitle from "../common/DialogWithTitle"; +import React, { useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { CodeDialogButton } from "../common/CodeDialogButton"; import { DurationText } from "../common/DurationText"; import { ActorLink, NodeLink } from "../common/links"; import rowStyles from "../common/RowStyles"; @@ -231,7 +229,9 @@ const TaskTable = ({ arrow interactive > -
{task_id}
+ + {task_id} + {name ? name : "-"} @@ -299,24 +299,14 @@ const TaskTable = ({ - ( -
- {key}: {val} -
- ), - )} - arrow - interactive - > -
- {Object.entries(required_resources || {}) - .map(([key, val]) => `${key}: ${val}`) - .join(", ")} -
-
+ {Object.entries(required_resources || {}).length > 0 ? ( + + ) : ( + "{}" + )}
); @@ -330,70 +320,29 @@ const TaskTable = ({ export default TaskTable; -const useTaskTableActionsStyles = makeStyles(() => - createStyles({ - errorDetails: { - whiteSpace: "pre", - }, - link: { - border: "none", - cursor: "pointer", - color: "#036DCF", - textDecoration: "underline", - background: "none", - }, - }), -); - type TaskTableActionsProps = { task: Task; }; const TaskTableActions = ({ task }: TaskTableActionsProps) => { - const classes = useTaskTableActionsStyles(); - const { ipLogMap } = useContext(GlobalContext); - const [showErrorDetailsDialog, setShowErrorDetailsDialog] = useState(false); - - const handleErrorClick = () => { - setShowErrorDetailsDialog(true); - }; - - const errorDetails = task.error_type - ? `Error Type: ${task.error_type}\n\n${task.error_message}` - : undefined; + const errorDetails = + task.error_type !== null && task.error_message !== null + ? `Error Type: ${task.error_type}\n\n${task.error_message}` + : undefined; return ( - {task?.profiling_data?.node_ip_address && - ipLogMap[task?.profiling_data?.node_ip_address] && - task.worker_id && - task.job_id && ( - - - Log - -
-
- )} + + Log + +
+ {errorDetails && ( - - )} - {showErrorDetailsDialog && errorDetails && ( - { - setShowErrorDetailsDialog(false); - }} - > -
{errorDetails}
-
+ code={errorDetails} + buttonText="Error" + /> )}
); diff --git a/dashboard/client/src/pages/actor/ActorDetail.tsx b/dashboard/client/src/pages/actor/ActorDetail.tsx index 8feb0a10f0e1..c134a1482e3e 100644 --- a/dashboard/client/src/pages/actor/ActorDetail.tsx +++ b/dashboard/client/src/pages/actor/ActorDetail.tsx @@ -1,5 +1,6 @@ import { makeStyles } from "@material-ui/core"; import React from "react"; +import { Outlet } from "react-router-dom"; import { CollapsibleSection } from "../../common/CollapsibleSection"; import { DurationText } from "../../common/DurationText"; import { formatDateFromTimeMs } from "../../common/formatUtils"; @@ -39,11 +40,37 @@ const useStyle = makeStyles((theme) => ({ }, })); +export const ActorDetailLayout = () => { + const { params, actorDetail } = useActorDetail(); + + return ( +
+ + +
+ ); +}; + const ActorDetailPage = () => { const classes = useStyle(); const { params, actorDetail, msg, isLoading } = useActorDetail(); - if (!actorDetail) { + if (isLoading || actorDetail === undefined) { return (
@@ -58,15 +85,6 @@ const ActorDetailPage = () => { return (
- - ; + actor: Pick; }; export const ActorLogs = ({ actor: { - jobId, + actorId, pid, address: { workerId, rayletId }, }, @@ -19,15 +19,13 @@ export const ActorLogs = ({ const tabs: MultiTabLogViewerTabDetails[] = [ { title: "stderr", - nodeId: rayletId, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.err`, + actorId, + suffix: "err", }, { title: "stdout", - nodeId: rayletId, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.out`, + actorId, + suffix: "out", }, { title: "system", diff --git a/dashboard/client/src/pages/job/AdvancedProgressBar/AdvancedProgressBar.tsx b/dashboard/client/src/pages/job/AdvancedProgressBar/AdvancedProgressBar.tsx index 6ba92e3ffcbf..d9fbb29d86d9 100644 --- a/dashboard/client/src/pages/job/AdvancedProgressBar/AdvancedProgressBar.tsx +++ b/dashboard/client/src/pages/job/AdvancedProgressBar/AdvancedProgressBar.tsx @@ -15,6 +15,7 @@ import { RiCloseLine, RiSubtractLine, } from "react-icons/ri"; +import { Link } from "react-router-dom"; import { ClassNameProps } from "../../../common/props"; import { JobProgressGroup, NestedJobProgressLink } from "../../../type/job"; import { MiniTaskProgressBar } from "../TaskProgressBar"; @@ -166,15 +167,21 @@ export const AdvancedProgressBarSegment = ({ }} /> {link ? ( - + link.type === "actor" ? ( + + ) : ( + + {name} + + ) ) : ( name )} diff --git a/dashboard/client/src/pages/job/JobDetailActorPage.tsx b/dashboard/client/src/pages/job/JobDetailActorPage.tsx index 91e23dbce2ff..0e0d09437bf5 100644 --- a/dashboard/client/src/pages/job/JobDetailActorPage.tsx +++ b/dashboard/client/src/pages/job/JobDetailActorPage.tsx @@ -15,23 +15,17 @@ const useStyle = makeStyles((theme) => ({ export const JobDetailActorsPage = () => { const classes = useStyle(); - const { job, params } = useJobDetail(); - - const pageInfo = job - ? { - title: "Actors", - id: "actors", - path: job.job_id ? `/jobs/${job.job_id}/actors` : undefined, - } - : { - title: "Actors", - id: "actors", - path: undefined, - }; + const { params } = useJobDetail(); return (
- +
@@ -42,23 +36,15 @@ export const JobDetailActorsPage = () => { export const JobDetailActorDetailWrapper = ({ children, }: PropsWithChildren<{}>) => { - const { job } = useJobDetail(); - - const pageInfo = job - ? { - title: "Actors", - id: "actors", - path: job.job_id ? `/jobs/${job.job_id}/actors` : undefined, - } - : { - title: "Actors", - id: "actors", - path: undefined, - }; - return (
- + {children}
); diff --git a/dashboard/client/src/pages/job/JobDetailInfoPage.tsx b/dashboard/client/src/pages/job/JobDetailInfoPage.tsx index bd6c746dd139..3f1a19ae661b 100644 --- a/dashboard/client/src/pages/job/JobDetailInfoPage.tsx +++ b/dashboard/client/src/pages/job/JobDetailInfoPage.tsx @@ -41,7 +41,7 @@ export const JobDetailInfoPage = () => { pageInfo={{ title: "Info", id: "job-info", - path: undefined, + path: "info", }} /> diff --git a/dashboard/client/src/pages/job/JobDetailLayout.tsx b/dashboard/client/src/pages/job/JobDetailLayout.tsx index 88685472e572..0d4119eda364 100644 --- a/dashboard/client/src/pages/job/JobDetailLayout.tsx +++ b/dashboard/client/src/pages/job/JobDetailLayout.tsx @@ -10,20 +10,20 @@ import { SideTabLayout, SideTabRouteLink } from "../layout/SideTabLayout"; import { useJobDetail } from "./hook/useJobDetail"; export const JobPage = () => { - const { job } = useJobDetail(); + const { job, params } = useJobDetail(); const jobId = job?.job_id ?? job?.submission_id; - const pageInfo = job + const pageInfo = jobId ? { title: jobId ?? "Job", pageTitle: jobId ? `${jobId} | Job` : undefined, id: "job-detail", - path: jobId ? `/jobs/${jobId}` : undefined, + path: jobId, } : { title: "Job", id: "job-detail", - path: undefined, + path: params.id, }; return (
diff --git a/dashboard/client/src/pages/job/JobDriverLogs.component.test.tsx b/dashboard/client/src/pages/job/JobDriverLogs.component.test.tsx index 7e6fbe21fb46..8dc5aab5fc0b 100644 --- a/dashboard/client/src/pages/job/JobDriverLogs.component.test.tsx +++ b/dashboard/client/src/pages/job/JobDriverLogs.component.test.tsx @@ -22,6 +22,7 @@ describe("JobDriverLogs", () => { render( ; }; export const JobDriverLogs = ({ job }: JobDriverLogsProps) => { - const { driver_node_id, submission_id } = job; + const { driver_node_id, submission_id, type } = job; const filename = submission_id ? `job-driver-${submission_id}.log` : undefined; @@ -42,16 +43,23 @@ export const JobDriverLogs = ({ job }: JobDriverLogsProps) => { link = undefined; } - // TODO(aguo): Support showing message for jobs not created via ray job submit - // instead of hiding the driver logs return ( diff --git a/dashboard/client/src/pages/layout/MainNavLayout.tsx b/dashboard/client/src/pages/layout/MainNavLayout.tsx index cb68b0dd7d00..2e317fcc3547 100644 --- a/dashboard/client/src/pages/layout/MainNavLayout.tsx +++ b/dashboard/client/src/pages/layout/MainNavLayout.tsx @@ -264,15 +264,24 @@ const MainNavBreadcrumbs = () => { return null; } + let currentPath = ""; + return (
{mainNavPageHierarchy.map(({ title, id, path }, index) => { + if (path) { + if (path.startsWith("/")) { + currentPath = path; + } else { + currentPath = `${currentPath}/${path}`; + } + } const linkOrText = path ? ( {title} diff --git a/dashboard/client/src/pages/layout/mainNavContext.ts b/dashboard/client/src/pages/layout/mainNavContext.ts index 0937fd082d2a..ff030d803670 100644 --- a/dashboard/client/src/pages/layout/mainNavContext.ts +++ b/dashboard/client/src/pages/layout/mainNavContext.ts @@ -11,13 +11,16 @@ export type MainNavPage = { pageTitle?: string; /** * This helps identifies the current page a user is on and highlights the nav bar correctly. - * This should be unique per page. + * This should be unique per page within an hiearchy. i.e. you should NOT put two pages with the same ID + * as parents or children of each other. * DO NOT change the pageId of a page. The behavior of the main nav and * breadcrumbs is undefined in that case. */ id: string; /** * URL to link to access this route. + * If this begins with a `/`, it is treated as an absolute path. + * If not, this is treated as a relative path and the path is appended to the parent breadcrumb's path. */ path?: string; }; diff --git a/dashboard/client/src/pages/log/hooks.ts b/dashboard/client/src/pages/log/hooks.ts index 0ee04401f771..fe333ba96a2e 100644 --- a/dashboard/client/src/pages/log/hooks.ts +++ b/dashboard/client/src/pages/log/hooks.ts @@ -1,25 +1,24 @@ import useSWR from "swr"; -import { getStateApiDownloadLogUrl, getStateApiLog } from "../../service/log"; +import { + getStateApiDownloadLogUrl, + getStateApiLog, + StateApiLogInput, +} from "../../service/log"; export const useStateApiLogs = ( - driver_node_id?: string | null, - filename?: string, + props: StateApiLogInput, + path: string | undefined, ) => { - const downloadUrl = - driver_node_id && filename - ? getStateApiDownloadLogUrl(driver_node_id, filename) - : undefined; + const downloadUrl = getStateApiDownloadLogUrl(props); const { data: log, isLoading, mutate, } = useSWR( - driver_node_id && filename - ? ["useDriverLogs", driver_node_id, filename] - : null, - async ([_, node_id, filename]) => { - return getStateApiLog(node_id, filename); + downloadUrl ? ["useDriverLogs", downloadUrl] : null, + async ([_]) => { + return getStateApiLog(props); }, ); @@ -27,6 +26,6 @@ export const useStateApiLogs = ( log: isLoading ? "Loading..." : log, downloadUrl, refresh: mutate, - path: filename, + path, }; }; diff --git a/dashboard/client/src/pages/node/ClusterDetailInfoPage.tsx b/dashboard/client/src/pages/node/ClusterDetailInfoPage.tsx index 8c8ecb211afc..a44636abebaf 100644 --- a/dashboard/client/src/pages/node/ClusterDetailInfoPage.tsx +++ b/dashboard/client/src/pages/node/ClusterDetailInfoPage.tsx @@ -27,7 +27,7 @@ export const ClusterDetailInfoPage = () => { pageInfo={{ title: "Cluster Info", id: "cluster-info", - path: undefined, + path: "info", }} /> diff --git a/dashboard/client/src/pages/overview/OverviewPage.tsx b/dashboard/client/src/pages/overview/OverviewPage.tsx index 3582107d3e92..2b9ed841ca72 100644 --- a/dashboard/client/src/pages/overview/OverviewPage.tsx +++ b/dashboard/client/src/pages/overview/OverviewPage.tsx @@ -39,6 +39,9 @@ const useStyles = makeStyles((theme) => maxWidth: `calc((100% - ${theme.spacing(3)}px * 2) / 3)`, }, }, + autoscalerCard: { + padding: theme.spacing(2, 3), + }, section: { marginTop: theme.spacing(4), }, @@ -70,12 +73,20 @@ export const OverviewPage = () => {
diff --git a/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx b/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx index a4c4086b6758..a9bfc0cb1b23 100644 --- a/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx +++ b/dashboard/client/src/pages/serve/ServeReplicaDetailPage.tsx @@ -188,21 +188,19 @@ const ServeReplicaLogs = ({ const { address: { workerId }, pid, - jobId, + actorId, } = actor; const tabs: MultiTabLogViewerTabDetails[] = [ { title: "stderr", - nodeId: node_id, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.err`, + actorId, + suffix: "err", }, { title: "stdout", - nodeId: node_id, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.out`, + actorId, + suffix: "out", }, { title: "system", diff --git a/dashboard/client/src/pages/serve/ServeSystemActorDetailPage.tsx b/dashboard/client/src/pages/serve/ServeSystemActorDetailPage.tsx index d702345e69e6..3aaf68a32500 100644 --- a/dashboard/client/src/pages/serve/ServeSystemActorDetailPage.tsx +++ b/dashboard/client/src/pages/serve/ServeSystemActorDetailPage.tsx @@ -135,14 +135,14 @@ export const ServeSystemActorDetail = ({ type ServeSystemActorLogsProps = { type: "controller" | "httpProxy"; - actor: Pick; + actor: Pick; systemLogFilePath: string; }; const ServeSystemActorLogs = ({ type, actor: { - jobId, + actorId, pid, address: { workerId, rayletId }, }, @@ -158,15 +158,13 @@ const ServeSystemActorLogs = ({ }, { title: "Actor Logs (stderr)", - nodeId: rayletId, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.err`, + actorId, + suffix: "err", }, { title: "Actor Logs (stdout)", - nodeId: rayletId, - // TODO(aguo): Have API return the log file name. - filename: `worker-${workerId}-${jobId}-${pid}.out`, + actorId, + suffix: "out", }, { title: "Actor Logs (system)", diff --git a/dashboard/client/src/pages/state/hook/useStateApi.ts b/dashboard/client/src/pages/state/hook/useStateApi.ts index d8ec1187784b..3ee375e4785a 100644 --- a/dashboard/client/src/pages/state/hook/useStateApi.ts +++ b/dashboard/client/src/pages/state/hook/useStateApi.ts @@ -1,6 +1,7 @@ import { AxiosResponse } from "axios"; import useSWR, { Key } from "swr"; import { PER_JOB_PAGE_REFRESH_INTERVAL_MS } from "../../../common/constants"; +import { getTask } from "../../../service/task"; import { AsyncFunction, StateApiResponse, @@ -29,3 +30,23 @@ export const useStateApiList = ( return data; }; + +export const useStateApiTask = (taskId: string | undefined) => { + const { data, isLoading } = useSWR( + taskId ? ["useStateApiTask", taskId] : null, + async ([_, taskId]) => { + const rsp = await getTask(taskId); + if (rsp?.data?.data?.result?.result) { + return rsp.data.data.result.result[0]; + } else { + return undefined; + } + }, + { refreshInterval: PER_JOB_PAGE_REFRESH_INTERVAL_MS }, + ); + + return { + task: data, + isLoading, + }; +}; diff --git a/dashboard/client/src/pages/task/TaskPage.tsx b/dashboard/client/src/pages/task/TaskPage.tsx new file mode 100644 index 000000000000..619dc5a2ead6 --- /dev/null +++ b/dashboard/client/src/pages/task/TaskPage.tsx @@ -0,0 +1,270 @@ +import { Box, createStyles, makeStyles, Typography } from "@material-ui/core"; +import React from "react"; +import { useParams } from "react-router-dom"; +import { CodeDialogButtonWithPreview } from "../../common/CodeDialogButton"; +import { CollapsibleSection } from "../../common/CollapsibleSection"; +import { DurationText } from "../../common/DurationText"; +import { formatDateFromTimeMs } from "../../common/formatUtils"; +import { generateActorLink, generateNodeLink } from "../../common/links"; +import { + MultiTabLogViewer, + MultiTabLogViewerTabDetails, +} from "../../common/MultiTabLogViewer"; +import { Section } from "../../common/Section"; +import Loading from "../../components/Loading"; +import { MetadataSection } from "../../components/MetadataSection"; +import { StatusChip } from "../../components/StatusChip"; +import { Task } from "../../type/task"; +import { MainNavPageInfo } from "../layout/mainNavContext"; +import { useStateApiTask } from "../state/hook/useStateApi"; + +const useStyles = makeStyles((theme) => + createStyles({ + root: { + padding: theme.spacing(2), + backgroundColor: "white", + }, + }), +); + +export const TaskPage = () => { + const { taskId } = useParams(); + const { task, isLoading } = useStateApiTask(taskId); + + const classes = useStyles(); + + return ( +
+ + +
+ ); +}; + +type TaskPageContentsProps = { + taskId?: string; + task?: Task; + isLoading: boolean; +}; + +const TaskPageContents = ({ + taskId, + task, + isLoading, +}: TaskPageContentsProps) => { + if (isLoading) { + return ; + } + + if (!task) { + return ( + Task with ID "{taskId}" not found. + ); + } + + const { + task_id, + actor_id, + end_time_ms, + start_time_ms, + node_id, + placement_group_id, + required_resources, + state, + type, + worker_id, + job_id, + func_or_class_name, + name, + } = task; + + return ( +
+ , + }, + { + label: "Job ID", + content: { + value: job_id, + copyableValue: job_id, + }, + }, + { + label: "Function or class name", + content: { + value: func_or_class_name, + }, + }, + { + label: "Actor ID", + content: actor_id + ? { + value: actor_id, + copyableValue: actor_id, + link: generateActorLink(actor_id), + } + : { + value: "-", + }, + }, + { + label: "Node ID", + content: node_id + ? { + value: node_id, + copyableValue: node_id, + link: generateNodeLink(node_id), + } + : { + value: "-", + }, + }, + { + label: "Worker ID", + content: worker_id + ? { + value: worker_id, + copyableValue: worker_id, + } + : { + value: "-", + }, + }, + { + label: "Type", + content: { + value: type, + }, + }, + { + label: "Placement group ID", + content: placement_group_id + ? { + value: placement_group_id, + copyableValue: placement_group_id, + } + : { + value: "-", + }, + }, + { + label: "Required resources", + content: + Object.entries(required_resources).length > 0 ? ( + + + + ) : ( + { + value: "{}", + } + ), + }, + { + label: "Started at", + content: { + value: start_time_ms ? formatDateFromTimeMs(start_time_ms) : "-", + }, + }, + { + label: "Ended at", + content: { + value: end_time_ms ? formatDateFromTimeMs(end_time_ms) : "-", + }, + }, + { + label: "Duration", + content: start_time_ms ? ( + + ) : ( + { + value: "-", + } + ), + }, + ]} + /> + +
+ +
+
+
+ ); +}; + +type TaskLogsProps = { + task: Task; +}; + +const TaskLogs = ({ + task: { task_id, error_message, error_type, worker_id, node_id }, +}: TaskLogsProps) => { + const errorDetails = + error_type !== null && error_message !== null + ? `Error Type: ${error_type}\n\n${error_message}` + : undefined; + + const tabs: MultiTabLogViewerTabDetails[] = [ + ...(worker_id !== null && node_id !== null + ? ([ + { + title: "stderr", + taskId: task_id, + suffix: "err", + }, + { + title: "stdout", + taskId: task_id, + suffix: "out", + }, + ] as const) + : []), + // TODO(aguo): uncomment once PID is available in the API. + // { + // title: "system", + // nodeId: node_id, + // // TODO(aguo): Have API return the log file name. + // filename: `python-core-worker-${worker_id}_${pid}.log`, + // }, + ...(errorDetails + ? [{ title: "Error stack trace", contents: errorDetails }] + : []), + ]; + return ; +}; diff --git a/dashboard/client/src/service/log.ts b/dashboard/client/src/service/log.ts index f4deeff96286..51ca4902b59b 100644 --- a/dashboard/client/src/service/log.ts +++ b/dashboard/client/src/service/log.ts @@ -50,13 +50,65 @@ export const getLogDetail = async (url: string) => { return rsp.data as string; }; -export const getStateApiDownloadLogUrl = (nodeId: string, fileName: string) => - `api/v0/logs/file?node_id=${encodeURIComponent( - nodeId, - )}&filename=${encodeURIComponent(fileName)}&lines=-1`; +export type StateApiLogInput = { + nodeId?: string | null; + /** + * If actorId is provided, nodeId is not necessary + */ + actorId?: string | null; + /** + * If taskId is provided, nodeId is not necessary + */ + taskId?: string | null; + suffix?: string; + /** + * If filename is provided, suffix is not necessary + */ + filename?: string | null; +}; + +export const getStateApiDownloadLogUrl = ({ + nodeId, + filename, + taskId, + actorId, + suffix, +}: StateApiLogInput) => { + if ( + nodeId === null || + actorId === null || + taskId === null || + filename === null + ) { + // Null means data is not ready yet. + return null; + } + const variables = [ + ...(nodeId !== undefined ? [`node_id=${encodeURIComponent(nodeId)}`] : []), + ...(filename !== undefined + ? [`filename=${encodeURIComponent(filename)}`] + : []), + ...(taskId !== undefined ? [`task_id=${encodeURIComponent(taskId)}`] : []), + ...(actorId !== undefined + ? [`actor_id=${encodeURIComponent(actorId)}`] + : []), + ...(suffix !== undefined ? [`suffix=${encodeURIComponent(suffix)}`] : []), + "lines=-1", + ]; + + return `api/v0/logs/file?${variables.join("&")}`; +}; -export const getStateApiLog = async (nodeId: string, fileName: string) => { - const resp = await get(getStateApiDownloadLogUrl(nodeId, fileName)); +export const getStateApiLog = async (props: StateApiLogInput) => { + const url = getStateApiDownloadLogUrl(props); + if (url === null) { + return undefined; + } + const resp = await get(url); + // Handle case where log file is empty. + if (resp.status === 200 && resp.data.length === 0) { + return ""; + } // TODO(aguo): get rid of this first byte check once we support state-api logs without this streaming byte. if (resp.data[0] !== "1") { throw new Error(resp.data.substring(1)); diff --git a/dashboard/client/src/service/log.unit.test.ts b/dashboard/client/src/service/log.unit.test.ts new file mode 100644 index 000000000000..c7e437df9779 --- /dev/null +++ b/dashboard/client/src/service/log.unit.test.ts @@ -0,0 +1,65 @@ +import { getStateApiDownloadLogUrl } from "./log"; + +describe("getStateApiDownloadLogUrl", () => { + it("only uses parameters provided but doesn't fetch when parameters are null", () => { + expect.assertions(8); + + expect( + getStateApiDownloadLogUrl({ + nodeId: "node-id", + filename: "file.log", + }), + ).toStrictEqual( + "api/v0/logs/file?node_id=node-id&filename=file.log&lines=-1", + ); + + expect( + getStateApiDownloadLogUrl({ + taskId: "task-id", + suffix: "err", + }), + ).toStrictEqual("api/v0/logs/file?task_id=task-id&suffix=err&lines=-1"); + + expect( + getStateApiDownloadLogUrl({ + taskId: "task-id", + suffix: "out", + }), + ).toStrictEqual("api/v0/logs/file?task_id=task-id&suffix=out&lines=-1"); + + expect( + getStateApiDownloadLogUrl({ + actorId: "actor-id", + suffix: "err", + }), + ).toStrictEqual("api/v0/logs/file?actor_id=actor-id&suffix=err&lines=-1"); + + expect( + getStateApiDownloadLogUrl({ + nodeId: null, + filename: "file.log", + }), + ).toBeNull(); + + expect( + getStateApiDownloadLogUrl({ + nodeId: null, + filename: null, + }), + ).toBeNull(); + + expect( + getStateApiDownloadLogUrl({ + taskId: null, + suffix: "err", + }), + ).toBeNull(); + + expect( + getStateApiDownloadLogUrl({ + actorId: null, + suffix: "err", + }), + ).toBeNull(); + }); +}); diff --git a/dashboard/client/src/service/task.ts b/dashboard/client/src/service/task.ts index 41a9355585ba..17441651de07 100644 --- a/dashboard/client/src/service/task.ts +++ b/dashboard/client/src/service/task.ts @@ -10,6 +10,13 @@ export const getTasks = (jobId: string | undefined) => { return get>(url); }; +export const getTask = (taskId: string) => { + const url = `api/v0/tasks?detail=1&limit=1&filter_keys=task_id&filter_predicates=%3D&filter_values=${encodeURIComponent( + taskId, + )}`; + return get>(url); +}; + export const downloadTaskTimelineHref = (jobId: string | undefined) => { let url = "/api/v0/tasks/timeline?download=1"; if (jobId) { diff --git a/dashboard/client/src/type/task.ts b/dashboard/client/src/type/task.ts index 168d3154f9bc..ddb65b47580e 100644 --- a/dashboard/client/src/type/task.ts +++ b/dashboard/client/src/type/task.ts @@ -27,7 +27,7 @@ export type Task = { state: TypeTaskStatus; job_id: string; node_id: string; - actor_id: string; + actor_id: string | null; placement_group_id: string | null; type: TypeTaskType; func_or_class_name: string;