I have a couple of Linode servers that need to be restarted often and I need to login to do that. I want to create a simple tool to automate those steps so I don't have to repeat those steps all the time.

I'll be creating a React app to enable me restart those servers from my localhost.

Setup - commit

Run npx create-react-app serverman; cd serverman; npm start. This will create the general app structure, create an initial Git commit and start the development server. Installation instructions can also be seen at the Create-React-app site.

Next, we'll download bulma.min.css and save to ./public and add <link rel="stylesheet" href="%PUBLIC_URL%/bulma.min.css" /> to the head of ./public/index.html.

Delete ./src/index.css and remove any reference to it in ./src/index.js.

Delete ./src/App.css and ./src/logo.svg and remove references to them in ./src/App.js.

Getting a personal access token - commit

Create or get your personal access token from your dashboard. Ensure your token has the linodes read & write scopes.

There are different ways of introducing our token to the app:

  • Hardcoding (a bad idea).
  • Using an environment variable.
  • Providing it as input to the app.

While I could go with either option 2 or 3, option 3 is more user-friendly. We'll be storing our token in localStorage so that we don't have to provide it every we refresh or open the app.

Run npm i -S prop-types to install PropTypes.

Replace the contents of App.js with:

import React, { Component } from 'react';
import Token from './Token';

class App extends Component {
  state = {
    linodes: [],
    token: ''
  };

  handleTokenChanged = (token) => {
    this.setState({ token });
  }

  render() {
    return (
      <div className="App">
        <nav className="navbar is-transparent">
          <div className="navbar-brand">
            <a className="navbar-item" href="/">
              <h1 className="title">Serverman</h1>
            </a>
          </div>
        </nav>
        <section className="section">
          <div className="container is-fluid">
            <Token tokenChanged={this.handleTokenChanged} />
          </div>
        </section>
      </div>
    );
  }
}

export default App;

In our state we declare a linodes array to store our server list when we retrieve them and a token string for our access token which we need for our API calls. Next, we have an event handler handleTokenChanged() to update our token when it changes. We pass this handler as a prop to our Token component in render().

Create a file ./src/Token.js and paste in:

import React, { Component } from 'react';
import { func } from 'prop-types';

class Token extends Component {
  static propTypes = {
    tokenChanged: func.isRequired
  };

  state = {
    tokenKey: 'serverman-token',
    token: '',
    tokenRef: React.createRef()
  };

  fetchToken = () => {
    const token = window.localStorage.getItem(this.state.tokenKey);

    if (token) {
      this.setState({ token });
      this.props.tokenChanged(token);
    }
  }

  saveToken = (token) => window.localStorage.setItem(this.state.tokenKey, token);

  handleSubmit = (e) => {
    e.preventDefault();
    e.stopPropagation();

    const token = this.state.tokenRef.current.value;

    if (! token || token === this.state.token) return;

    this.setState({ token });
    this.saveToken(token);
    this.props.tokenChanged(token);
  }

  componentDidMount() {
    this.fetchToken();
  }

  render() {
    const { state: { token, tokenRef } } = this;
    const inputClassNames = `input${token ? '' : ' is-danger'}`;
    const action = token ? 'Update' : 'Set';
    const styles = { marginBottom: '20px' };

    return (
      <form style={styles} onSubmit={this.handleSubmit}>
        <label className="label">Linode access token</label>
        <div className="field has-addons">
          <div className="control">
            <input 
              defaultValue={token}
              ref={tokenRef}
              className={inputClassNames} 
              type="text" 
              placeholder="Linode access token" />
          </div>
          <div className="control">
            <button type="submit" className="button is-info">{action} token</button>
          </div>
        </div>  
      </form>
    );
  }
}

export default Token;

The prop declares a function prop we'll call each time we update our token to provide the updated token to parent components. In our state, we keep track of our localStorage key, a string to hold our current token value and a ref to our input element so we can read new token values provided. fetchToken() and saveToken() retrieve and store our token to localStorage. handleSubmit() retrieves the current value in the input element and if the value is different from our current token and not empty updates our state, stores the token in localStorage and calls the function provided in the props.

Listing servers - commit

Most of the action will happen in App.js.

Add the following to state:

state = {
    ...
    fetching: false,
    baseUrl: 'https://api.linode.com/v4/'
};

To fetch our servers, we can do it either in componentDidMount() when App mounts or in handleTokenChanged() when our token is updated. I choose handleTokenChanged() as we can guarantee we have a valid token there. There are few chances that our token is available when App mounts (and we would have to refactor things to make that happen).

Update the setState() call in handleTokenChanged() so we fetch our servers after token is updated:

this.setState({ token, fetching: true }, () => this.fetchLinodes());

Add fetchLinodes() to fetch the servers:

fetchLinodes = () => {
    fetch(`${this.state.baseUrl}linode/instances`, {
        method: 'get',
        headers: new Headers({ Authorization: `Bearer ${this.state.token}` })
    })
        .then(response => response.status === 200 ? response.json(): ({ data: [] }))
        .then(({ data }) => this.setState({ linodes: data }))
        .finally(() => this.setState({ fetching: false }));
}

In our render(), add these under <Token/>:

<div className="columns">
    <div className="column">
        {
        !this.state.fetching ? '' :
        <progress className="progress is-small is-info">Fetching linodes</progress>
       }
       <Linodes linodes={this.state.linodes} />
    </div>
</div>

To complete this step, add ./src/Linodes.js and paste in:

import React, { Component } from 'react';
import { array } from 'prop-types';

class Linodes extends Component {
  static propTypes = {
    linodes: array.isRequired
  };

  render() {
    const { props: { linodes } } = this;

    return (
      <div className="list is-hoverable">
        {
          linodes.map((l, i) => <a href="#" className="list-item" key={i}>{l.label}</a>)
        }
      </div>
    );
  }
}

export default Linodes;

On the frontend, you'll now see your linodes, if you have any.

View linode - commit

When we click on a linode we need to mark it visually as the active linode and keep track of our selection. Here's the updated portions of Linodes that take care of that:

class Linodes extends Component {
  ...

  state = {
    selectedLinode: -1
  };

  selectLinode = (selectedLinode) => {
    this.setState({ selectedLinode });
  }

  render() {
    const { props: { linodes } } = this,
      listItemClassNames = (index) => `list-item${index === this.state.selectedLinode ? ' is-active' : ''}`;

    return (
      <div className="list is-hoverable">
        {
          linodes.map((l, i) => 
            <a href="#" 
              onClick={_ => this.selectLinode(i)} 
              className={listItemClassNames(i)} 
              key={l.id}>
              {l.label}
            </a>)
        }
      </div>
    );
  }
}

Next, we need to inform App that a new linode has been selected.

Update Linodes.js:

import { array, func } from 'prop-types';

class Linodes extends Component {
  static propTypes = {
    ...
    linodeSelected: func.isRequired
  };

  selectLinode = (selectedLinode) => {
    ...
    this.props.linodeSelected(selectedLinode);
  }

}

export default Linodes;

Update App.js:

class App extends Component {
  state = {
    ...
    selectedLinode: -1
  };

  linodeSelected = (selectedLinode) => this.setState({ selectedLinode });

  render() {
    return (
      ...
      <Linodes linodes={this.state.linodes} linodeSelected={this.linodeSelected} />
    );
  }
}

Now that App is tracking selected linodes, let's add a component to view the linode.

Update App.js:

import Linode from './Linode';

class App extends Component {
  ...
  
  render() {
    const { state:  { fetching, linodes, selectedLinode } } = this;

    return (
      <div className="App">
        ...
        <section className="section">
          <div className="container is-fluid">
            ...
            <div className="columns">
              <div className="column">
                {
                  !fetching ? <p/> :
                  <progress className="progress is-small is-info">Fetching linodes</progress>
                }
                <Linodes linodes={linodes} linodeSelected={this.linodeSelected} />
              </div>
              {
                selectedLinode !== -1 ? 
                  <div className="column">
                    <Linode key={selectedLinode} linode={linodes.find((_, i) => i === selectedLinode)} />
                  </div>
                  :
                  <div />
              }
            </div>
          </div>
        </section>
      </div>
    );
  }
}

In the new ./src/Linode.js, paste in:

import React, { Component } from 'react';
import { object } from 'prop-types';

class Linode extends Component {
  static propTypes = {
    linode: object.isRequired
  };

  render() {
    const { props: { linode: { type, status, label, image, ipv4: [ip] } } } = this;

    return (
      <div className="card">
        <header className="card-header">
          <p className="card-header-title">
            {label}
            &emsp;
            <span className="subtitle is-6">(IP: {ip})</span>
          </p>
          {this.renderStatus(status)}
        </header>
        <div className="card-content">
          <div className="content">
            {this.genericTag('Type', type)}
            {this.genericTag('Image', image)}
          </div>
        </div>
      </div>
    );
  }

  renderStatus = (status) => {
    const classNames = `tag${status === 'running' ? ' is-primary' : ' is-danger'}`;
    
    return (
      <div className="tags has-addons" style={{ marginRight: '10px' }}>
        <span className="tag">Status</span>
        <span className={classNames}>{status}</span>
      </div>
    );
  }

  genericTag = (label, value) => (
    <div className="tags has-addons">
      <span className="tag is-dark">{label}</span>
      <span className="tag">{value}</span>
    </div>
  );
}

export default Linode;

We display some of the info contained in the linode prop.

Restart linode - commit

Now we come to the purpose of this app.

First, pass some needed props from App to Linode:

class App extends Component {
  ...
  
  render() {
    const { state:  { baseUrl, ..., token } } = this;

    return (
      <div className="App">
        ...
        <section className="section">
          <div className="container is-fluid">
            <Token tokenChanged={this.handleTokenChanged} />
            <div className="columns">
              ...
              {
                selectedLinode !== -1 ? 
                  <div className="column">
                    <Linode 
                      key={selectedLinode} 
                      baseUrl={baseUrl} 
                      token={token} 
                      linode={linodes.find((_, i) => i === selectedLinode)} />
                  </div>
                  :
                  <div />
              }
            </div>
          </div>
        </section>
      </div>
    );
  }
}

Then, let's examine the relevant updates in Linode:

import { object, string } from 'prop-types';

class Linode extends Component {
  static propTypes = {
    ...
    baseUrl: string.isRequired,
    token: string.isRequired
  };

  state = {
    working: false,
    result: 'idle',
    status: this.props.linode.status
  };

  resetResult = () => this.setState({ result: 'idle' });

  restart = () => {
    this.resetResult();

    this.setState({ working: true });

    const { props: { baseUrl, linode: { id }, token } } = this;

    fetch(`${baseUrl}linode/instances/${id}/reboot`, {
      method: 'post',
      headers: new Headers({ Authorization: `Bearer ${token}` })
    })
    .then(response => response.status === 200 ? 'rebooting': 'failure')
    .then((result) => {
      if (result === 'failure') {
        this.setState({ result, working: false, status: 'offline' });
      } else {
        this.setState({ status: result });
        this.runUntilRebootDone();
      }
    });
  }

  runUntilRebootDone = () => {
    const { props: { baseUrl, linode: { id }, token } } = this;

    fetch(`${baseUrl}linode/instances/${id}`, {
      method: 'get',
      headers: new Headers({ Authorization: `Bearer ${token}` })
    })
    .then(response => response.status === 200 ? response.json() : ({ status: 'indeterminate' }))
    .then(({ status }) => {
      if (status === 'rebooting') setTimeout(() => this.runUntilRebootDone(), 2000);
      
      if (status === 'running') this.setState({ working: false, result: 'success', status });

      if (status === 'indeterminate') this.setState({ working: false, result: 'failure', status: 'offline' });
    });
  }

  render() {
    const { props: { linode: { type, label, image, ipv4: [ip] } }, state: { status, result, working } } = this,
      buttonClasses = `button is-medium${working ? ' is-loading' : ''}`;

    return (
      <div className="card">
        ...
        <div className="card-content">
          <div className="content">
            ...
            <hr />
            <button disabled={working} onClick={this.restart} className={buttonClasses}>Restart</button>
            <div style={{ marginBottom: '10px', marginTop: '10px' }}>
            {
              !working ? <p/> :
              <progress className="progress is-small is-warning">Rebooting...</progress>
            }
            </div>
            {this.renderRestartResult(result, working)}
          </div>
        </div>
      </div>
    );
  }

  renderStatus = (status) => {
    const classNames = `tag${status === 'running' ? ' is-primary' : 
      (status === 'rebooting' ? ' is-warning' : ' is-danger')}`;
    
    ...
  }

  renderRestartResult = (result, working) => {
    if (result === 'idle' || working) return <p/>;

    const classNames = `notification${result === 'success' ? ' is-success' : ' is-danger'}`;

    return (
      <div className={classNames} style={{ marginTop: '10px' }}>
        <button onClick={this.resetResult} className="delete"></button>
        {result === 'success' ? 'Restart succeeded' : 'Restart failed'}
      </div>
    );
  }

  ...
}

export default Linode;

We declare the appropriate props.

In restart(), we call the relevant API endpoint and if the call is successful, we keep checking the API in runUntilRebootDone() until the status changes from rebooting to any of the terminal states. If the reboot call worked or failed we render a notification in renderRestartResult().

Deployment - commit

Build a production bundle with npm run build.

We'll be using Express to server our app. Install by running npm i -S express.

Create ./server.js and paste in:

const express = require('express');
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, 'build')));

app.get('/', (_, res) => res.sendFile(path.join(__dirname, 'build', 'index.html')));

app.listen(13000, () => console.log('Example app listening on port 13000'));

You can now run the app with node server.js or add a serve command in package.json to run that command.

Deployment with Docker - commit

Create ./Dockerfile with:

FROM node:12.10.0-alpine

WORKDIR /app

RUN npm i express

COPY --chown=node:node . .

EXPOSE 13000
CMD ["node", "server.js"]

Create ./.dockerignore with:

.git
node_modules
src
public
README.md
Dockerfile
.dockerignore
.gitignore
package*.json

Create ./docker-compose.yml with:

version: '3'
services:
  serverman:
    build: .
    image: serverman
    container_name: serverman
    restart: unless-stopped
    network_mode: host

Build with docker-compose build and run with docker-compose up or docker-compose up -d if you want to run in the background.