[Dashboard] Add initial version of new dashboard (#5730)

This commit is contained in:
Mitchell Stern
2019-09-23 08:50:40 -07:00
committed by Robert Nishihara
parent 56ab9a00bb
commit 98dcc1d440
28 changed files with 14738 additions and 67 deletions
+11 -1
View File
@@ -38,6 +38,10 @@ NUMPY_VERSIONS=("1.14.5"
mkdir -p $DOWNLOAD_DIR
mkdir -p .whl
# Use the latest version of Node.js in order to build the dashboard.
source $HOME/.nvm/nvm.sh
nvm use node
for ((i=0; i<${#PY_VERSIONS[@]}; ++i)); do
PY_VERSION=${PY_VERSIONS[i]}
PY_INST=${PY_INSTS[i]}
@@ -58,10 +62,16 @@ for ((i=0; i<${#PY_VERSIONS[@]}; ++i)); do
PIP_CMD="$(dirname $PYTHON_EXE)/pip$PY_MM"
pushd /tmp
# Install latest version of pip to avoid brownouts
# Install latest version of pip to avoid brownouts.
curl https://bootstrap.pypa.io/get-pip.py | $PYTHON_EXE
popd
# Build the dashboard so its static assets can be included in the wheel.
pushd python/ray/dashboard/client
npm ci
npm run build
popd
pushd python
# Setuptools on CentOS is too old to install arrow 0.9.0, therefore we upgrade.
$PIP_CMD install --upgrade setuptools
+14
View File
@@ -35,14 +35,28 @@ export PATH=$PATH:/root/bin
rm -f /usr/bin/python2
ln -s /opt/python/cp27-cp27m/bin/python2 /usr/bin/python2
# Install and use the latest version of Node.js in order to build the dashboard.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.34.0/install.sh | bash
source $HOME/.nvm/nvm.sh
nvm install node
nvm use node
mkdir .whl
for ((i=0; i<${#PYTHONS[@]}; ++i)); do
PYTHON=${PYTHONS[i]}
NUMPY_VERSION=${NUMPY_VERSIONS[i]}
# The -f flag is passed twice to also run git clean in the arrow subdirectory.
# The -d flag removes directories. The -x flag ignores the .gitignore file,
# and the -e flag ensures that we don't remove the .whl directory.
git clean -f -f -x -d -e .whl
# Build the dashboard so its static assets can be included in the wheel.
pushd python/ray/dashboard/client
npm ci
npm run build
popd
pushd python
# Fix the numpy version because this will be the oldest numpy version we can
# support.
@@ -88,6 +88,9 @@ setup_commands:
# Install basics.
- sudo apt-get update
- sudo apt-get install -y build-essential curl unzip
# Install Node.js in order to build the dashboard.
- curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash
- sudo apt-get install -y nodejs
# Install Anaconda.
- wget https://repo.continuum.io/archive/Anaconda3-5.0.1-Linux-x86_64.sh || true
- bash Anaconda3-5.0.1-Linux-x86_64.sh -b -p $HOME/anaconda3 || true
@@ -95,7 +98,8 @@ setup_commands:
# Build Ray.
- git clone https://github.com/ray-project/ray || true
- ray/ci/travis/install-bazel.sh
- pip install boto3==1.4.8 cython==0.29.0
- cd ray/python/ray/dashboard/client; npm ci; npm run build
- pip install boto3==1.4.8 cython==0.29.0 aiohttp psutil setproctitle
- cd ray/python; pip install -e . --verbose
# Custom commands that will be run on the head node after common setup.
@@ -107,7 +111,7 @@ worker_setup_commands: []
# Command to start ray on the head node. You don't need to change this.
head_start_ray_commands:
- ray stop
- ulimit -n 65536; ray start --head --num-redis-shards=10 --redis-port=6379 --autoscaling-config=~/ray_bootstrap_config.yaml
- ulimit -n 65536; ray start --head --include-webui --num-redis-shards=10 --redis-port=6379 --autoscaling-config=~/ray_bootstrap_config.yaml
# Command to start ray on worker nodes. You don't need to change this.
worker_start_ray_commands:
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+65
View File
@@ -0,0 +1,65 @@
This project was bootstrapped with `Create React App
<https://github.com/facebook/create-react-app>`__.
Available Scripts
-----------------
In the project directory, you can run:
``npm start``
~~~~~~~~~~~~~
Runs the app in the development mode. Open `http://localhost:3000
<http://localhost:3000>`__ to view it in the browser.
The page will reload if you make edits. You will also see any lint errors in the
console.
``npm test``
~~~~~~~~~~~~
Launches the test runner in the interactive watch mode. See the section about
`running tests
<https://facebook.github.io/create-react-app/docs/running-tests>`__ for more
information.
``npm run build``
~~~~~~~~~~~~~~~~~
Builds the app for production to the ``build`` folder. It correctly bundles
React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes. Your app is ready to
be deployed!
See the section about `deployment
<https://facebook.github.io/create-react-app/docs/deployment>`__ for more
information.
``npm run eject``
~~~~~~~~~~~~~~~~~
Note: this is a one-way operation. Once you ``eject``, you cant go back!
If you arent satisfied with the build tool and configuration choices, you can
``eject`` at any time. This command will remove the single build dependency from
your project.
Instead, it will copy all the configuration files and the transitive
dependencies (Webpack, Babel, ESLint, etc) right into your project so you have
full control over them. All of the commands except ``eject`` will still work,
but they will point to the copied scripts so you can tweak them. At this point
youre on your own.
You dont have to ever use ``eject``. The curated feature set is suitable for
small and middle deployments, and you shouldnt feel obligated to use this
feature. However we understand that this tool wouldnt be useful if you couldnt
customize it when you are ready for it.
Learn More
----------
You can learn more in the `Create React App documentation
<https://facebook.github.io/create-react-app/docs/getting-started>`__.
To learn React, check out the `React documentation <https://reactjs.org/>`__.
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.3.3",
"@material-ui/icons": "^4.2.1",
"@types/classnames": "^2.2.9",
"@types/jest": "24.0.18",
"@types/node": "12.7.2",
"@types/react": "16.9.2",
"@types/react-dom": "16.9.0",
"@types/react-router-dom": "^4.3.5",
"classnames": "^2.2.6",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-router-dom": "^5.0.1",
"react-scripts": "3.1.1",
"typeface-roboto": "0.0.75",
"typescript": "3.5.3"
},
"devDependencies": {
"prettier": "^1.18.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Ray Dashboard</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import CssBaseline from "@material-ui/core/CssBaseline";
import React from "react";
import { BrowserRouter } from "react-router-dom";
import Dashboard from "./Dashboard";
class App extends React.Component {
render() {
return (
<BrowserRouter>
<CssBaseline />
<Dashboard />
</BrowserRouter>
);
}
}
export default App;
@@ -0,0 +1,492 @@
import Link from "@material-ui/core/Link";
import { Theme } from "@material-ui/core/styles/createMuiTheme";
import createStyles from "@material-ui/core/styles/createStyles";
import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Typography from "@material-ui/core/Typography";
import AddIcon from "@material-ui/icons/Add";
import RemoveIcon from "@material-ui/icons/Remove";
import classNames from "classnames";
import React from "react";
import { Route } from "react-router";
import { Link as RouterLink } from "react-router-dom";
import Errors from "./Errors";
import Logs from "./Logs";
import UsageBar from "./UsageBar";
const formatByteAmount = (amount: number, unit: "mebibyte" | "gibibyte") =>
`${(
amount / (unit === "mebibyte" ? Math.pow(1024, 2) : Math.pow(1024, 3))
).toFixed(1)} ${unit === "mebibyte" ? "MiB" : "GiB"}`;
const formatUsage = (
used: number,
total: number,
unit: "mebibyte" | "gibibyte"
) => {
const usedFormatted = formatByteAmount(used, unit);
const totalFormatted = formatByteAmount(total, unit);
const percent = (100 * used) / total;
return `${usedFormatted} / ${totalFormatted} (${percent.toFixed(0)}%)`;
};
const formatUptime = (bootTime: number) => {
const uptimeSecondsTotal = Date.now() / 1000 - bootTime;
const uptimeSeconds = Math.floor(uptimeSecondsTotal) % 60;
const uptimeMinutes = Math.floor(uptimeSecondsTotal / 60) % 60;
const uptimeHours = Math.floor(uptimeSecondsTotal / 60 / 60) % 24;
const uptimeDays = Math.floor(uptimeSecondsTotal / 60 / 60 / 24);
const pad = (value: number) => value.toString().padStart(2, "0");
return [
uptimeDays ? `${uptimeDays}d` : "",
`${pad(uptimeHours)}h`,
`${pad(uptimeMinutes)}m`,
`${pad(uptimeSeconds)}s`
].join(" ");
};
const styles = (theme: Theme) =>
createStyles({
root: {
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
"& > :not(:first-child)": {
marginTop: theme.spacing(2)
}
},
cell: {
padding: theme.spacing(1),
textAlign: "center",
"&:last-child": {
paddingRight: theme.spacing(1)
}
},
expandCollapseCell: {
cursor: "pointer"
},
expandCollapseIcon: {
color: theme.palette.text.secondary,
fontSize: "1.5em",
verticalAlign: "middle"
},
cpuUsage: {
minWidth: 60
},
secondary: {
color: theme.palette.text.secondary
}
});
// TODO(mitchellstern): Add JSON schema validation for the node info.
interface NodeInfo {
clients: Array<{
now: number;
hostname: string;
ip: string;
boot_time: number;
cpu: number;
cpus: [number, number];
mem: [number, number, number];
disk: {
[path: string]: {
total: number;
free: number;
used: number;
percent: number;
};
};
load_avg: [[number, number, number], [number, number, number]];
net: [number, number];
workers: Array<{
pid: number;
create_time: number;
name: string;
cmdline: string[];
cpu_percent: number;
cpu_times: {
system: number;
children_system: number;
user: number;
children_user: number;
};
memory_info: {
pageins: number;
pfaults: number;
vms: number;
rss: number;
};
memory_full_info: null;
}>;
}>;
logs: {
[ip: string]: {
[pid: string]: string[];
};
};
errors: {
[jobId: string]: Array<{
message: string;
timestamp: number;
type: string;
}>;
};
}
interface State {
response: {
result: NodeInfo;
timestamp: number;
} | null;
error: string | null;
expanded: {
[hostname: string]: boolean;
};
}
class Component extends React.Component<WithStyles<typeof styles>, State> {
state: State = {
response: null,
error: null,
expanded: {}
};
fetchNodeInfo = async () => {
try {
const url = new URL(
"/api/node_info",
process.env.NODE_ENV === "development"
? "http://localhost:8080"
: window.location.href
);
const response = await fetch(url.toString());
const json = await response.json();
this.setState({ response: json, error: null });
} catch (error) {
this.setState({ response: null, error: error.toString() });
} finally {
setTimeout(this.fetchNodeInfo, 1000);
}
};
toggleExpand = (hostname: string) => () => {
this.setState(state => ({
expanded: {
...state.expanded,
[hostname]: !state.expanded[hostname]
}
}));
};
async componentDidMount() {
await this.fetchNodeInfo();
}
render() {
const { classes } = this.props;
const { response, error, expanded } = this.state;
if (error !== null) {
return (
<Typography className={classes.root} color="error">
{error}
</Typography>
);
}
if (response === null) {
return (
<Typography className={classes.root} color="textSecondary">
Loading...
</Typography>
);
}
const { result, timestamp } = response;
const logCounts: {
[hostname: string]: {
perWorker: {
[pid: string]: number;
};
total: number;
};
} = {};
const errorCounts: {
[hostname: string]: {
perWorker: {
[pid: string]: number;
};
total: number;
};
} = {};
for (const client of result.clients) {
logCounts[client.hostname] = { perWorker: {}, total: 0 };
errorCounts[client.hostname] = { perWorker: {}, total: 0 };
for (const worker of client.workers) {
logCounts[client.hostname].perWorker[worker.pid] = 0;
errorCounts[client.hostname].perWorker[worker.pid] = 0;
}
}
for (const ip of Object.keys(result.logs)) {
let hostname: string | null = null;
for (const client of result.clients) {
if (ip === client.ip) {
hostname = client.hostname;
break;
}
}
if (hostname !== null) {
for (const pid of Object.keys(result.logs[ip])) {
const logCount = result.logs[ip][pid].length;
if (pid in logCounts[hostname].perWorker) {
logCounts[hostname].perWorker[pid] = logCount;
}
logCounts[hostname].total += logCount;
}
}
}
for (const jobErrors of Object.values(result.errors)) {
for (const error of jobErrors) {
const match = error.message.match(/\(pid=(\d+), host=(.*?)\)/);
if (match !== null) {
const pid = match[1];
const hostname = match[2];
if (hostname in errorCounts) {
if (pid in errorCounts[hostname].perWorker) {
errorCounts[hostname].perWorker[pid]++;
}
errorCounts[hostname].total++;
}
}
}
}
const ipToHostname: { [ip: string]: string } = {};
for (const client of result.clients) {
ipToHostname[client.ip] = client.hostname;
}
return (
<div className={classes.root}>
<Typography variant="h5">Ray Dashboard</Typography>
<Table>
<TableHead>
<TableRow>
<TableCell className={classes.cell} />
<TableCell className={classes.cell}>Hostname</TableCell>
<TableCell className={classes.cell}>Workers</TableCell>
<TableCell className={classes.cell}>Uptime</TableCell>
<TableCell className={classes.cell}>CPU</TableCell>
<TableCell className={classes.cell}>RAM</TableCell>
<TableCell className={classes.cell}>Disk</TableCell>
{/*<TableCell className={classes.cell}>Sent</TableCell>*/}
{/*<TableCell className={classes.cell}>Received</TableCell>*/}
<TableCell className={classes.cell}>Logs</TableCell>
<TableCell className={classes.cell}>Errors</TableCell>
</TableRow>
</TableHead>
<TableBody>
{result.clients.map(client => {
return (
<React.Fragment key={client.hostname}>
<TableRow hover>
<TableCell
className={classNames(
classes.cell,
classes.expandCollapseCell
)}
onClick={this.toggleExpand(client.hostname)}
>
{!expanded[client.hostname] ? (
<AddIcon className={classes.expandCollapseIcon} />
) : (
<RemoveIcon className={classes.expandCollapseIcon} />
)}
</TableCell>
<TableCell className={classes.cell}>
{client.hostname}
</TableCell>
<TableCell className={classes.cell}>
{client.workers.length}
</TableCell>
<TableCell className={classes.cell}>
{formatUptime(client.boot_time)}
</TableCell>
<TableCell className={classes.cell}>
<div className={classes.cpuUsage}>
<UsageBar
percent={client.cpu}
text={`${client.cpu.toFixed(1)}%`}
/>
</div>
</TableCell>
<TableCell className={classes.cell}>
<UsageBar
percent={
(100 * (client.mem[0] - client.mem[1])) /
client.mem[0]
}
text={formatUsage(
client.mem[0] - client.mem[1],
client.mem[0],
"gibibyte"
)}
/>
</TableCell>
<TableCell className={classes.cell}>
<UsageBar
percent={
(100 * client.disk["/"].used) / client.disk["/"].total
}
text={formatUsage(
client.disk["/"].used,
client.disk["/"].total,
"gibibyte"
)}
/>
</TableCell>
{/*<TableCell className={classes.cell}>{(client.net[0] / Math.pow(1024, 2)).toFixed(3)} MiB/s</TableCell>*/}
{/*<TableCell className={classes.cell}>{(client.net[1] / Math.pow(1024, 2)).toFixed(3)} MiB/s</TableCell>*/}
<TableCell className={classes.cell}>
{logCounts[client.hostname].total === 0 ? (
<span className={classes.secondary}>No logs</span>
) : (
<Link
component={RouterLink}
to={`/logs/${client.hostname}`}
>
View all logs (
{logCounts[client.hostname].total.toLocaleString()}{" "}
{logCounts[client.hostname].total === 1
? "line"
: "lines"}
)
</Link>
)}
</TableCell>
<TableCell className={classes.cell}>
{errorCounts[client.hostname].total === 0 ? (
<span className={classes.secondary}>No errors</span>
) : (
<Link
component={RouterLink}
to={`/errors/${client.hostname}`}
>
View all errors (
{errorCounts[client.hostname].total.toLocaleString()})
</Link>
)}
</TableCell>
</TableRow>
{expanded[client.hostname] &&
client.workers.map((worker, index: number) => (
<TableRow hover key={index}>
<TableCell className={classes.cell} />
<TableCell className={classes.cell}>
{worker.cmdline[0].split(":", 2)[0]} (PID:{" "}
{worker.pid})
</TableCell>
<TableCell className={classes.cell}>
{worker.cmdline[0].split(":", 2)[1] || (
<span className={classes.secondary}>Idle</span>
)}
</TableCell>
<TableCell className={classes.cell}>
{formatUptime(worker.create_time)}
</TableCell>
<TableCell className={classes.cell}>
<UsageBar
percent={worker.cpu_percent}
text={`${worker.cpu_percent.toFixed(1)}%`}
/>
</TableCell>
<TableCell className={classes.cell}>
<UsageBar
percent={
(100 * worker.memory_info.rss) / client.mem[0]
}
text={formatByteAmount(
worker.memory_info.rss,
"mebibyte"
)}
/>
</TableCell>
<TableCell className={classes.cell}>
<span className={classes.secondary}>
Not available
</span>
</TableCell>
<TableCell className={classes.cell}>
{logCounts[client.hostname].perWorker[worker.pid] ===
0 ? (
<span className={classes.secondary}>No logs</span>
) : (
<Link
component={RouterLink}
to={`/logs/${client.hostname}/${worker.pid}`}
>
View log (
{logCounts[client.hostname].perWorker[
worker.pid
].toLocaleString()}{" "}
{logCounts[client.hostname].perWorker[
worker.pid
] === 1
? "line"
: "lines"}
)
</Link>
)}
</TableCell>
<TableCell className={classes.cell}>
{errorCounts[client.hostname].perWorker[
worker.pid
] === 0 ? (
<span className={classes.secondary}>No errors</span>
) : (
<Link
component={RouterLink}
to={`/errors/${client.hostname}/${worker.pid}`}
>
View errors (
{errorCounts[client.hostname].perWorker[
worker.pid
].toLocaleString()}
)
</Link>
)}
</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
<Typography align="center">
Last updated: {new Date(timestamp * 1000).toLocaleString()}
</Typography>
<Route
path="/logs/:hostname/:pid?"
render={props => (
<Logs {...props} ipToHostname={ipToHostname} logs={result.logs} />
)}
/>
<Route
path="/errors/:hostname/:pid?"
render={props => <Errors {...props} errors={result.errors} />}
/>
</div>
);
}
}
export default withStyles(styles)(Component);
+135
View File
@@ -0,0 +1,135 @@
import Dialog from "@material-ui/core/Dialog";
import IconButton from "@material-ui/core/IconButton";
import { Theme } from "@material-ui/core/styles/createMuiTheme";
import createStyles from "@material-ui/core/styles/createStyles";
import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles";
import Typography from "@material-ui/core/Typography";
import CloseIcon from "@material-ui/icons/Close";
import React from "react";
import { RouteComponentProps } from "react-router";
import NumberedLines from "./NumberedLines";
const styles = (theme: Theme) =>
createStyles({
paper: {
padding: theme.spacing(3)
},
closeButton: {
position: "absolute",
right: theme.spacing(1),
top: theme.spacing(1),
zIndex: 1
},
title: {
borderBottomColor: theme.palette.divider,
borderBottomStyle: "solid",
borderBottomWidth: 1,
lineHeight: 1,
marginBottom: theme.spacing(3),
paddingBottom: theme.spacing(3),
position: "relative",
"&:not(:first-of-type)": {
marginTop: theme.spacing(6)
}
},
error: {
"&:not(:last-child)": {
marginBottom: theme.spacing(3)
}
},
timestamp: {
marginBottom: theme.spacing(1)
}
});
interface Props {
errors: {
[jobId: string]: Array<{
message: string;
timestamp: number;
type: string;
}>;
};
}
class Component extends React.Component<
Props &
WithStyles<typeof styles> &
RouteComponentProps<{ hostname: string; pid: string | undefined }>
> {
handleClose = () => {
this.props.history.push("/");
};
render() {
const { classes, errors, match } = this.props;
const { hostname, pid } = match.params;
let errorsForHost: {
[pid: string]: Array<{
lines: string[];
timestamp: number;
}>;
} = {};
for (const jobErrors of Object.values(errors)) {
for (const error of jobErrors) {
const match = error.message.match(/\(pid=(\d+), host=(.*?)\)/);
if (match !== null && match[2] === hostname) {
const pid = match[1];
if (!(pid in errorsForHost)) {
errorsForHost[pid] = [];
}
errorsForHost[pid].push({
lines: error.message
.replace(/\u001b\[\d+m/g, "") // eslint-disable-line no-control-regex
.trim()
.split("\n"),
timestamp: error.timestamp
});
}
}
}
const errorsToDisplay =
pid === undefined
? errorsForHost
: { [pid]: pid in errorsForHost ? errorsForHost[pid] : [] };
return (
<Dialog
classes={{ paper: classes.paper }}
fullWidth
maxWidth="md"
onClose={this.handleClose}
open
scroll="body"
>
<IconButton className={classes.closeButton} onClick={this.handleClose}>
<CloseIcon />
</IconButton>
{Object.entries(errorsToDisplay).map(([pid, errors]) => (
<React.Fragment key={pid}>
<Typography className={classes.title}>
{hostname} (PID: {pid})
</Typography>
{errors.length > 0 ? (
errors.map(({ lines, timestamp }, index) => (
<div className={classes.error} key={index}>
<Typography className={classes.timestamp}>
Error at {new Date(timestamp * 1000).toLocaleString()}
</Typography>
<NumberedLines lines={lines} />
</div>
))
) : (
<Typography color="textSecondary">No errors found.</Typography>
)}
</React.Fragment>
))}
</Dialog>
);
}
}
export default withStyles(styles)(Component);
+108
View File
@@ -0,0 +1,108 @@
import Dialog from "@material-ui/core/Dialog";
import IconButton from "@material-ui/core/IconButton";
import { Theme } from "@material-ui/core/styles/createMuiTheme";
import createStyles from "@material-ui/core/styles/createStyles";
import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles";
import Typography from "@material-ui/core/Typography";
import CloseIcon from "@material-ui/icons/Close";
import React from "react";
import { RouteComponentProps } from "react-router";
import NumberedLines from "./NumberedLines";
const styles = (theme: Theme) =>
createStyles({
paper: {
padding: theme.spacing(3)
},
closeButton: {
position: "absolute",
right: theme.spacing(1),
top: theme.spacing(1),
zIndex: 1
},
title: {
borderBottomColor: theme.palette.divider,
borderBottomStyle: "solid",
borderBottomWidth: 1,
lineHeight: 1,
marginBottom: theme.spacing(3),
paddingBottom: theme.spacing(3),
position: "relative",
"&:not(:first-of-type)": {
marginTop: theme.spacing(3)
}
}
});
interface Props {
ipToHostname: {
[ip: string]: string;
};
logs: {
[ip: string]: {
[pid: string]: string[];
};
};
}
class Component extends React.Component<
Props &
WithStyles<typeof styles> &
RouteComponentProps<{ hostname: string; pid: string | undefined }>
> {
handleClose = () => {
this.props.history.push("/");
};
render() {
const { classes, ipToHostname, logs, match } = this.props;
const { hostname, pid } = match.params;
let logsForHost: {
[pid: string]: string[];
} = {};
for (const ip of Object.keys(ipToHostname)) {
if (ipToHostname[ip] === hostname) {
if (ip in logs) {
logsForHost = logs[ip];
}
break;
}
}
const logsToDisplay =
pid === undefined
? logsForHost
: { [pid]: pid in logsForHost ? logsForHost[pid] : [] };
return (
<Dialog
classes={{ paper: classes.paper }}
fullWidth
maxWidth="md"
onClose={this.handleClose}
open
scroll="body"
>
<IconButton className={classes.closeButton} onClick={this.handleClose}>
<CloseIcon />
</IconButton>
{Object.entries(logsToDisplay).map(([pid, lines]) => (
<React.Fragment key={pid}>
<Typography className={classes.title}>
{hostname} (PID: {pid})
</Typography>
{lines.length > 0 ? (
<NumberedLines lines={lines} />
) : (
<Typography color="textSecondary">No logs found.</Typography>
)}
</React.Fragment>
))}
</Dialog>
);
}
}
export default withStyles(styles)(Component);
@@ -0,0 +1,69 @@
import { Theme } from "@material-ui/core/styles/createMuiTheme";
import createStyles from "@material-ui/core/styles/createStyles";
import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import classNames from "classnames";
import React from "react";
const styles = (theme: Theme) =>
createStyles({
root: {
overflowX: "auto"
},
cell: {
borderWidth: 0,
fontFamily: "SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace",
padding: 0,
"&:last-child": {
paddingRight: 0
}
},
lineNumber: {
color: theme.palette.text.secondary,
paddingRight: theme.spacing(2),
textAlign: "right",
verticalAlign: "top",
width: "1%",
// Use a ::before pseudo-element for the line number so that it won't
// interact with user selections or searching.
"&::before": {
content: "attr(data-line-number)"
}
},
line: {
textAlign: "left",
whiteSpace: "pre-wrap"
}
});
interface Props {
lines: string[];
}
class Component extends React.Component<Props & WithStyles<typeof styles>> {
render() {
const { classes, lines } = this.props;
return (
<Table>
<TableBody>
{lines.map((line, index) => (
<TableRow key={index}>
<TableCell
className={classNames(classes.cell, classes.lineNumber)}
data-line-number={index + 1}
/>
<TableCell className={classNames(classes.cell, classes.line)}>
{line}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
}
export default withStyles(styles)(Component);
@@ -0,0 +1,64 @@
import { Theme } from "@material-ui/core/styles/createMuiTheme";
import createStyles from "@material-ui/core/styles/createStyles";
import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles";
import React from "react";
const blend = (
[r1, g1, b1]: number[],
[r2, g2, b2]: number[],
ratio: number
) => [
r1 * (1 - ratio) + r2 * ratio,
g1 * (1 - ratio) + g2 * ratio,
b1 * (1 - ratio) + b2 * ratio
];
const styles = (theme: Theme) =>
createStyles({
root: {
borderColor: theme.palette.divider,
borderStyle: "solid",
borderWidth: 1
}
});
interface Props {
percent: number;
text: string;
}
class Component extends React.Component<Props & WithStyles<typeof styles>> {
render() {
const { classes, text } = this.props;
let { percent } = this.props;
percent = Math.max(percent, 0);
percent = Math.min(percent, 100);
const minColor = [0, 255, 0];
const maxColor = [255, 0, 0];
const leftColor = minColor;
const rightColor = blend(minColor, maxColor, percent / 100);
const alpha = 0.2;
const gradient = `
linear-gradient(
to right,
rgba(${leftColor.join(",")}, ${alpha}) 0%,
rgba(${rightColor.join(",")}, ${alpha}) ${percent}%,
transparent ${percent}%
)
`;
// Use a nested `div` here because the right border is affected by the
// gradient background otherwise.
return (
<div className={classes.root}>
<div style={{ background: gradient }}>{text}</div>
</div>
);
}
}
export default withStyles(styles)(Component);
@@ -0,0 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import "typeface-roboto";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}
+61 -36
View File
@@ -4,8 +4,8 @@ from __future__ import print_function
try:
import aiohttp.web
except ModuleNotFoundError:
print("The reporter requires aiohttp to run.")
except ImportError:
print("The dashboard requires aiohttp to run.")
import sys
sys.exit(1)
@@ -20,9 +20,11 @@ import yaml
from pathlib import Path
from collections import Counter
from collections import defaultdict
from operator import itemgetter
from typing import Dict
import ray
import ray.ray_constants as ray_constants
import ray.utils
@@ -60,7 +62,15 @@ class Dashboard(object):
self.temp_dir = temp_dir
self.node_stats = NodeStats(redis_address, redis_password)
self.app = aiohttp.web.Application(middlewares=[self.auth_middleware])
# Setting the environment variable RAY_DASHBOARD_DEV=1 disables some
# security checks in the dashboard server to ease development while
# using the React dev server. Specifically, when this option is set, we
# disable the token-based authentication mechanism and allow
# cross-origin requests to be made.
self.is_dev = os.environ.get("RAY_DASHBOARD_DEV") == "1"
self.app = aiohttp.web.Application(
middlewares=[] if self.is_dev else [self.auth_middleware])
self.setup_routes()
@aiohttp.web.middleware
@@ -100,34 +110,27 @@ class Dashboard(object):
return forbidden()
async def get_index(req) -> aiohttp.web.Response:
return aiohttp.web.FileResponse(
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "index.html"))
async def get_resource(req) -> aiohttp.web.Response:
try:
path = req.match_info["x"]
except KeyError:
return forbidden()
if path not in ["main.css", "main.js"]:
return forbidden()
return aiohttp.web.FileResponse(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"res/{}".format(path)))
"client/build/index.html"))
async def json_response(result=None, error=None,
ts=None) -> aiohttp.web.Response:
if ts is None:
ts = datetime.datetime.utcnow()
return aiohttp.web.json_response({
"result": result,
"timestamp": to_unix_time(ts),
"error": error,
})
headers = None
if self.is_dev:
headers = {"Access-Control-Allow-Origin": "*"}
return aiohttp.web.json_response(
{
"result": result,
"timestamp": to_unix_time(ts),
"error": error,
},
headers=headers)
async def ray_config(_) -> aiohttp.web.Response:
try:
@@ -163,12 +166,17 @@ class Dashboard(object):
return await json_response(result=D, ts=now)
self.app.router.add_get("/", get_index)
self.app.router.add_get("/index.html", get_index)
self.app.router.add_get("/index.htm", get_index)
self.app.router.add_get("/res/{x}", get_resource)
static_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "client/build/static")
if not os.path.isdir(static_dir):
raise ValueError(
"Dashboard static asset directory not found at '{}'. If "
"installing from source, please follow the additional steps "
"required to build the dashboard.".format(static_dir))
self.app.router.add_static("/static", static_dir)
self.app.router.add_get("/api/node_info", node_info)
self.app.router.add_get("/api/super_client_table", node_info)
self.app.router.add_get("/api/ray_config", ray_config)
self.app.router.add_get("/{_}", get_forbidden)
@@ -193,6 +201,12 @@ class NodeStats(threading.Thread):
self._node_stats = {}
self._node_stats_lock = threading.Lock()
# Mapping from IP address to PID to list of log lines
self._logs = defaultdict(lambda: defaultdict(list))
ray.init(redis_address=redis_address, redis_password=redis_password)
super().__init__()
def calculate_totals(self) -> Dict:
@@ -263,18 +277,30 @@ class NodeStats(threading.Thread):
"totals": self.calculate_totals(),
"tasks": self.calculate_tasks(),
"clients": node_stats,
"logs": self._logs,
"errors": ray.errors(all_jobs=True),
}
def run(self):
p = self.redis_client.pubsub(ignore_subscribe_messages=True)
p.psubscribe(self.redis_key)
logger.info("NodeStats: subscribed to {}".format(self.redis_key))
log_channel = ray.gcs_utils.LOG_FILE_CHANNEL
p.subscribe(log_channel)
logger.info("NodeStats: subscribed to {}".format(log_channel))
for x in p.listen():
try:
D = json.loads(ray.utils.decode(x["data"]))
with self._node_stats_lock:
self._node_stats[D["hostname"]] = D
channel = ray.utils.decode(x["channel"])
if channel == log_channel:
D = json.loads(ray.utils.decode(x["data"]))
self._logs[D["ip"]][D["pid"]].extend(D["lines"])
else:
D = json.loads(ray.utils.decode(x["data"]))
self._node_stats[D["hostname"]] = D
except Exception:
logger.exception(traceback.format_exc())
continue
@@ -327,15 +353,14 @@ if __name__ == "__main__":
args = parser.parse_args()
ray.utils.setup_logger(args.logging_level, args.logging_format)
dashboard = Dashboard(
args.redis_address,
args.http_port,
args.token,
args.temp_dir,
redis_password=args.redis_password,
)
try:
dashboard = Dashboard(
args.redis_address,
args.http_port,
args.token,
args.temp_dir,
redis_password=args.redis_password,
)
dashboard.run()
except Exception as e:
# Something went wrong, so push an error to all drivers.
+7 -11
View File
@@ -13,7 +13,7 @@ from socket import AddressFamily
try:
import psutil
except ModuleNotFoundError:
except ImportError:
print("The reporter requires psutil to run.")
import sys
sys.exit(1)
@@ -48,14 +48,8 @@ def jsonify_asdict(o):
return json.dumps(recursive_asdict(o))
def running_worker(s):
if "ray_worker" not in s:
return False
if s == "ray_worker":
return False
return True
def is_worker(cmdline):
return cmdline and cmdline[0].startswith("ray_")
def determine_ip_address():
@@ -127,8 +121,10 @@ class Reporter(object):
def get_workers():
return [
x.as_dict(attrs=[
"pid", "create_time", "cpu_times", "name", "memory_full_info"
]) for x in psutil.process_iter() if running_worker(x.name())
"pid", "create_time", "cpu_percent", "cpu_times", "name",
"cmdline", "memory_info", "memory_full_info"
]) for x in psutil.process_iter(attrs=["cmdline"])
if is_worker(x.info["cmdline"])
]
def get_load_avg(self):
+2 -1
View File
@@ -978,10 +978,11 @@ def start_dashboard(redis_address,
try:
import aiohttp # noqa: F401
import psutil # noqa: F401
import setproctitle # noqa: F401
except ImportError:
raise ImportError(
"Failed to start the dashboard. The dashboard requires Python 3 "
"as well as 'pip install aiohttp psutil'.")
"as well as 'pip install aiohttp psutil setproctitle'.")
process_info = start_ray_process(
command,
+22 -4
View File
@@ -3,15 +3,33 @@ from __future__ import division
from __future__ import print_function
import sys
import time
import pytest
import requests
import ray
@pytest.mark.skipif(
sys.version_info < (3, 5, 3), reason="requires python3.5.3 or higher")
def test_get_webui():
addresses = ray.init(include_webui=True)
def test_get_webui(shutdown_only):
addresses = ray.init(include_webui=True, num_cpus=1)
webui_url = addresses["webui_url"]
assert ray.worker.get_webui_url() == webui_url
assert ray.get_webui_url() == webui_url
ray.shutdown()
base, token = webui_url.split("?")
assert token.startswith("token=")
start_time = time.time()
while True:
try:
node_info = requests.get(base + "api/node_info?" + token).json()
break
except requests.exceptions.ConnectionError:
if time.time() > start_time + 30:
raise Exception(
"Timed out while waiting for dashboard to start.")
assert node_info["error"] is None
assert node_info["result"] is not None
assert isinstance(node_info["timestamp"], float)
+14 -7
View File
@@ -2,6 +2,7 @@ from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
import glob
import os
import re
import shutil
@@ -27,9 +28,6 @@ ray_files = [
"ray/core/src/ray/raylet/raylet_monitor",
"ray/core/src/ray/raylet/raylet",
"ray/dashboard/dashboard.py",
"ray/dashboard/index.html",
"ray/dashboard/res/main.css",
"ray/dashboard/res/main.js",
]
# These are the directories where automatically generated Python protobuf
@@ -52,6 +50,18 @@ ray_project_files = [
"ray/projects/templates/requirements.txt"
]
ray_dashboard_files = [
"ray/dashboard/client/build/favicon.ico",
"ray/dashboard/client/build/index.html",
]
for dirname in ["css", "js", "media"]:
ray_dashboard_files += glob.glob(
"ray/dashboard/client/build/static/{}/*".format(dirname))
optional_ray_files += ray_autoscaler_files
optional_ray_files += ray_project_files
optional_ray_files += ray_dashboard_files
if "RAY_USE_NEW_GCS" in os.environ and os.environ["RAY_USE_NEW_GCS"] == "on":
ray_files += [
"ray/core/src/credis/build/src/libmember.so",
@@ -59,15 +69,12 @@ if "RAY_USE_NEW_GCS" in os.environ and os.environ["RAY_USE_NEW_GCS"] == "on":
"ray/core/src/credis/redis/src/redis-server"
]
optional_ray_files += ray_autoscaler_files
optional_ray_files += ray_project_files
extras = {
"rllib": [
"pyyaml", "gym[atari]", "opencv-python-headless", "lz4", "scipy"
],
"debug": ["psutil", "setproctitle", "py-spy"],
"dashboard": ["psutil", "aiohttp"],
"dashboard": ["aiohttp", "psutil", "setproctitle"],
"serve": ["uvicorn", "pygments", "werkzeug"],
}