Commit 37190fe7 authored by Maciej Lenartowicz's avatar Maciej Lenartowicz

Resolve "Manage list of devices"

parent df5fe11b
......@@ -5,11 +5,11 @@
.PHONY: all venv prepare-dev install install-js install-local-reforis watch-js build-js lint lint-js lint-js-fix lint-web test test-js test-web test-js-update-snapshots create-messages init-langs update-messages compile-messages clean
DEV_PYTHON=python3.7
ROUTER_PYTHON=python3.6
VENV_NAME?=venv
VENV_BIN=$(shell pwd)/$(VENV_NAME)/bin
PYTHON=python3
JS_DIR=./js
LANGS = cs da de el en fi fo fr hr hu it ja ko lt nb nb_NO nl pl ro ru sk sv
......@@ -40,29 +40,29 @@ all:
venv: $(VENV_NAME)/bin/activate
$(VENV_NAME)/bin/activate: setup.py
test -d $(VENV_NAME) || $(DEV_PYTHON) -m virtualenv -p $(DEV_PYTHON) $(VENV_NAME)
test -d $(VENV_NAME) || $(PYTHON) -m virtualenv -p $(PYTHON) $(VENV_NAME)
# Some problem in latest version of setuptools during extracting translations.
$(VENV_BIN)/$(DEV_PYTHON) -m pip install -U pip setuptools==39.1.0
$(VENV_BIN)/$(DEV_PYTHON) -m pip install -e .[devel]
$(VENV_BIN)/$(PYTHON) -m pip install -U pip setuptools==39.1.0
$(VENV_BIN)/$(PYTHON) -m pip install -e .[devel]
touch $(VENV_NAME)/bin/activate
prepare-env:
which npm || curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
which npm || sudo apt install -y nodejs
which $(DEV_PYTHON) || sudo apt install -y $(DEV_PYTHON) $(DEV_PYTHON)-pip
which virtualenv || sudo $(DEV_PYTHON) -m pip install virtualenv
which $(PYTHON) || sudo apt install -y $(PYTHON) $(PYTHON)-pip
which virtualenv || sudo $(PYTHON) -m pip install virtualenv
prepare-dev:
cd $(JS_DIR); npm install
make venv
install:
$(ROUTER_PYTHON) -m pip install -e .
$(PYTHON) -m pip install -e .
ln -sf /tmp/reforis-netboot/reforis_static/reforis_netboot /tmp/reforis/reforis_static/
/etc/init.d/lighttpd restart
install-js: js/package.json
cd $(JS_DIR); npm install --save-dev
install-local-reforis:
$(VENV_BIN)/$(DEV_PYTHON) -m pip install -e ../reforis
$(VENV_BIN)/$(PYTHON) -m pip install -e ../reforis
watch-js:
cd $(JS_DIR); npm run-script watch
......@@ -75,14 +75,14 @@ lint-js:
lint-js-fix:
cd $(JS_DIR); npm run lint:fix
lint-web: venv
$(VENV_BIN)/$(DEV_PYTHON) -m pylint --rcfile=pylintrc reforis_netboot
$(VENV_BIN)/$(DEV_PYTHON) -m pycodestyle --config=pycodestyle reforis_netboot
$(VENV_BIN)/$(PYTHON) -m pylint --rcfile=pylintrc reforis_netboot
$(VENV_BIN)/$(PYTHON) -m pycodestyle --config=pycodestyle reforis_netboot
test: test-js test-web
test-js:
cd $(JS_DIR); npm test
test-web: venv
$(VENV_BIN)/$(DEV_PYTHON) -m pytest -vv tests
$(VENV_BIN)/$(PYTHON) -m pytest -vv tests
test-js-update-snapshots:
cd $(JS_DIR); npm test -- -u
......@@ -95,7 +95,7 @@ init-langs: create-messages
-d reforis_netboot/translations/ -l $$lang \
; done
update-messages:
$(VENV_BIN)/pybabel update -i ./reforis_netboot/translations/messages.pot -d ./reforis/translations
$(VENV_BIN)/pybabel update -i ./reforis_netboot/translations/messages.pot -d ./reforis_netboot/translations
compile-messages:
$(VENV_BIN)/pybabel compile -f -d ./reforis_netboot/translations
......@@ -104,4 +104,4 @@ clean:
rm -rf $(VENV_NAME) *.eggs *.egg-info dist build .cache
rm -rf dist build *.egg-info
rm -rf $(JS_DIR)/node_modules/ reforis_static/reforis_netboot/js/app.min.js
$(ROUTER_PYTHON) -m pip uninstall -y reforis_netboot
$(PYTHON) -m pip uninstall -y reforis_netboot
......@@ -11,7 +11,9 @@ const API_URL_PREFIX = `${REFORIS_URL_PREFIX}/netboot/api`;
const API_URLs = new Proxy(
{
example: "/example",
devices: "/devices",
accept: "/accept",
unpair: "/unpair",
},
{
get: (target, name) => `${API_URL_PREFIX}${target[name]}`,
......
/*
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState } from "react";
import PropTypes from "prop-types";
import { API_STATE, Spinner, ErrorMessage } from "foris";
import DevicesTable from "./DevicesTable";
import {
useLoadDevices, useAcceptDevice, useUpdateOnAccept, useUnpairDevice, useUpdateOnUnpair,
} from "./hooks";
Devices.propTypes = {
ws: PropTypes.object.isRequired,
};
export default function Devices({ ws }) {
const [devices, setDevices] = useState([]);
const loadDevicesState = useLoadDevices(setDevices);
const [acceptState, acceptDevice] = useAcceptDevice();
useUpdateOnAccept(ws, setDevices);
const [unpairState, unpairDevice] = useUnpairDevice();
useUpdateOnUnpair(ws, setDevices);
if (loadDevicesState === API_STATE.INIT
|| [loadDevicesState, acceptState, unpairState].includes(API_STATE.SENDING)) {
return <Spinner />;
}
if (loadDevicesState === API_STATE.ERROR) {
return <ErrorMessage />;
}
return (
<DevicesTable
devices={devices}
acceptDevice={acceptDevice}
unpairDevice={unpairDevice}
/>
);
}
.netboot-devices-table td {
vertical-align: middle;
height: 4rem;
}
.netboot-serial-number {
width: 40%;
}
.netboot-status {
width: 20%;
text-align: center;
}
.netboot-action {
width: 40%;
}
/*
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import "./DevicesTable.css";
import DevicesTableRow, { deviceShape } from "./DevicesTableRow";
DevicesTable.propTypes = {
devices: PropTypes.arrayOf(deviceShape).isRequired,
acceptDevice: PropTypes.func.isRequired,
unpairDevice: PropTypes.func.isRequired,
};
export default function DevicesTable({ devices, acceptDevice, unpairDevice }) {
if (!devices || devices.length === 0) {
return <p className="text-muted text-center">{_("No netboot devices available.")}</p>;
}
return (
<div className="table-responsive">
<table className="table table-hover netboot-devices-table">
<thead>
<tr>
<th scope="col" className="netboot-serial-number">{_("Serial Number")}</th>
<th scope="col" className="netboot-status">{_("Paired")}</th>
<th scope="col" className="netboot-action" aria-label={_("Change status")} />
</tr>
</thead>
<tbody>
{devices.map((device) => (
<DevicesTableRow
key={device.serial}
device={device}
acceptDevice={acceptDevice}
unpairDevice={unpairDevice}
/>
))}
</tbody>
</table>
</div>
);
}
/*
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { Button, SpinnerElement } from "foris";
import "./DevicesTable.css";
import DEVICE_STATES from "./deviceStates";
export const deviceShape = PropTypes.shape({
serial: PropTypes.string.isRequired,
state: PropTypes.oneOf(Object.values(DEVICE_STATES)),
});
DevicesTableRow.propTypes = {
device: deviceShape.isRequired,
acceptDevice: PropTypes.func.isRequired,
unpairDevice: PropTypes.func.isRequired,
};
export default function DevicesTableRow({ device, acceptDevice, unpairDevice }) {
let actionButton;
if (device.state === DEVICE_STATES.ACCEPTED) {
actionButton = <UnpairButton serial={device.serial} unpairDevice={unpairDevice} />;
} else if (device.state === DEVICE_STATES.INCOMING) {
actionButton = <AcceptButton serial={device.serial} acceptDevice={acceptDevice} />;
}
return (
<tr>
<td>{device.serial}</td>
<td className="text-center">
{device.state === DEVICE_STATES.TRANSFERING
? <SpinnerElement />
: <StatusIcon key={`${device.serial}-${device.state}`} status={device.state} />}
</td>
<td className="text-center">{actionButton}</td>
</tr>
);
}
StatusIcon.propTypes = {
status: PropTypes.string,
};
function StatusIcon({ status }) {
let className = "fa-question-circle text-warning";
let statusDescription = _("Unknown status");
if (status === DEVICE_STATES.ACCEPTED) {
className = "fa-check-circle text-success";
statusDescription = _("Paired");
} else if (status === DEVICE_STATES.INCOMING) {
className = "fa-times-circle text-primary";
statusDescription = _("Awaiting acceptance");
}
/*
* Wrapper tag is required to properly remove icon because "i" element
* is actually replaced by "svg" element.
*/
return (
<span>
<i className={`fa fa-lg ${className}`} title={statusDescription} />
</span>
);
}
UnpairButton.propTypes = {
serial: PropTypes.string.isRequired,
unpairDevice: PropTypes.func.isRequired,
};
function UnpairButton({ serial, unpairDevice }) {
return (
<Button onClick={() => unpairDevice({ suffix: serial })}>
{_("Unpair device")}
</Button>
);
}
AcceptButton.propTypes = {
serial: PropTypes.string.isRequired,
acceptDevice: PropTypes.func.isRequired,
};
function AcceptButton({ serial, acceptDevice }) {
return (
<Button onClick={() => acceptDevice({ suffix: serial })}>
{_("Accept pairing request")}
</Button>
);
}
......@@ -5,22 +5,21 @@
* See /LICENSE for more information.
*/
import React, { useEffect } from "react";
import React from "react";
import PropTypes from "prop-types";
import { useAPIGet } from "foris";
import Devices from "./Devices";
import API_URLs from "API";
export default function Netboot() {
const [, getExample] = useAPIGet(API_URLs.example);
useEffect(() => {
getExample();
}, [getExample]);
Netboot.propTypes = {
ws: PropTypes.object.isRequired,
};
export default function Netboot({ ws }) {
return (
<>
<h1>Netboot</h1>
<p>{_("Add your components here")}</p>
<h1>{_("Netboot")}</h1>
<p>{_("Manage devices which can be booted from this router through network.")}</p>
<Devices ws={ws} />
</>
);
}
/*
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import mockAxios from "jest-mock-axios";
import {
render, act, wait, waitForElement, getByText, getByTitle, getAllByTitle, getByRole, queryByText, fireEvent,
} from "foris/testUtils/customTestRender";
import { mockJSONError } from "foris/testUtils/network";
import { mockSetAlert } from "foris/testUtils/alertContextMock";
import { WebSockets } from "foris";
import devices from "./__fixtures__/devices";
import Devices from "../Devices";
describe("<Devices />", () => {
let container;
let webSockets;
beforeEach(() => {
webSockets = new WebSockets();
({ container } = render(<Devices ws={webSockets} />));
});
it("should render spinner", () => {
expect(container).toMatchSnapshot();
});
it("should render table", async () => {
expect(mockAxios.get).toBeCalledWith(
"/reforis/netboot/api/devices", expect.anything(),
);
mockAxios.mockResponse({ data: devices });
await waitForElement(() => getByText(container, devices[0].serial));
expect(container).toMatchSnapshot();
});
it("should handle GET error", async () => {
mockJSONError();
await wait(() => expect(
getByText(container, "An error occurred while fetching data."),
).toBeTruthy());
});
describe("accept", () => {
let acceptButton;
beforeEach(async () => {
mockAxios.mockResponse({ data: devices });
await waitForElement(() => getByText(container, devices[0].serial));
acceptButton = getByText(container, "Accept pairing request");
});
it("should handle API error", async () => {
// Unpair device
fireEvent.click(acceptButton);
expect(mockAxios.put).toBeCalledWith(
`/reforis/netboot/api/accept/${devices[0].serial}`, undefined, expect.anything(),
);
// Handle error
const errorMessage = "API didn't handle this well";
mockJSONError(errorMessage);
await wait(() => {
expect(mockSetAlert).toHaveBeenCalledWith(errorMessage);
});
expect(getByTitle(container, "Awaiting acceptance")).toBeDefined();
});
it("should display spinner during request", async () => {
fireEvent.click(acceptButton);
expect(container).toMatchSnapshot();
});
it("should display spinner while processing acceptance request", async () => {
fireEvent.click(acceptButton);
mockAxios.mockResponse({ data: { task_id: "5542" } });
await waitForElement(() => getByText(container, devices[0].serial));
act(() =>
webSockets.dispatch({ module: "netboot", action: "accept", data: { serial: devices[0].serial, status: "started" } })
);
expect(getByRole(container, "status")).toBeDefined();
});
it("should handle succesfully processed acceptance request", async () => {
expect(getAllByTitle(container, "Paired").length).toBe(1);
fireEvent.click(acceptButton);
mockAxios.mockResponse({ data: { task_id: "5542" } });
await waitForElement(() => getByText(container, devices[0].serial));
act(() =>
webSockets.dispatch({ module: "netboot", action: "accept", data: { serial: devices[0].serial, status: "succeeded" } })
);
expect(getAllByTitle(container, "Paired").length).toBe(2);
});
it("should handle failed acceptance request", async () => {
fireEvent.click(acceptButton);
mockAxios.mockResponse({ data: { task_id: "5542" } });
await waitForElement(() => getByText(container, devices[0].serial));
act(() =>
webSockets.dispatch({ module: "netboot", action: "accept", data: { serial: devices[0].serial, status: "failed" } })
);
expect(mockSetAlert).toHaveBeenCalledWith("Cannot pair devices.");
// Request didn't change its status
expect(getByTitle(container, "Awaiting acceptance")).toBeDefined();
});
});
describe("unpair", () => {
let unpairButton;
beforeEach(async () => {
mockAxios.mockResponse({ data: devices });
await waitForElement(() => getByText(container, devices[1].serial));
unpairButton = getByText(container, "Unpair device");
});
it("should handle API error", async () => {
// Unpair device
fireEvent.click(unpairButton);
expect(mockAxios.put).toBeCalledWith(
`/reforis/netboot/api/unpair/${devices[1].serial}`, undefined, expect.anything(),
);
// Handle error
const errorMessage = "API didn't handle this well";
mockJSONError(errorMessage);
await wait(() => {
expect(mockSetAlert).toHaveBeenCalledWith(errorMessage);
});
});
it("should display spinner during request", async () => {
fireEvent.click(unpairButton);
expect(container).toMatchSnapshot();
});
it("should remove device from table on success", async () => {
fireEvent.click(unpairButton);
mockAxios.mockResponse({ data: { result: true } });
await waitForElement(() => getByText(container, devices[1].serial));
act(() =>
webSockets.dispatch({ module: "netboot", action: "revoke", data: { serial: devices[1].serial } })
);
await wait(() => expect(queryByText(container, devices[1].serial)).toBeNull());
});
});
});
......@@ -6,15 +6,15 @@
*/
import React from "react";
import mockAxios from "jest-mock-axios";
import { render } from "foris/testUtils/customTestRender";
import { WebSockets } from "foris";
import Netboot from "../Netboot";
describe("<Netboot />", () => {
it("should render component", () => {
const { getByText } = render(<Netboot />);
expect(getByText("Netboot")).toBeDefined();
expect(mockAxios.get).toBeCalledWith("/reforis/netboot/api/example", expect.anything());
const webSockets = new WebSockets();
const { container } = render(<Netboot ws={webSockets} />);
expect(container).toMatchSnapshot();
});
});
/*
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
const devices = [
{ serial: "1234", state: "incoming" },
{ serial: "5678", state: "accepted" },
];
export default devices;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Devices /> accept should display spinner during request 1`] = `
<div>
<div
class="spinner-wrapper my-3 text-center"
>
<div
class="spinner-border"
role="status"
>
<span
class="sr-only"
/>
</div>
</div>
</div>
`;
exports[`<Devices /> should render spinner 1`] = `
<div>
<div
class="spinner-wrapper my-3 text-center"
>
<div
class="spinner-border"
role="status"
>
<span
class="sr-only"
/>
</div>
</div>
</div>
`;
exports[`<Devices /> should render table 1`] = `
<div>
<div
class="table-responsive"
>
<table
class="table table-hover netboot-devices-table"
>
<thead>
<tr>
<th
class="netboot-serial-number"
scope="col"
>
Serial Number
</th>
<th
class="netboot-status"
scope="col"
>
Paired
</th>
<th
aria-label="Change status"
class="netboot-action"
scope="col"
/>
</tr>
</thead>
<tbody>
<tr>
<td>
1234
</td>
<td
class="text-center"
>
<span>
<i
class="fa fa-lg fa-times-circle text-primary"
title="Awaiting acceptance"
/>
</span>
</td>
<td
class="text-center"
>
<button
class="btn btn-primary "
type="button"
>
Accept pairing request
</button>
</td>
</tr>
<tr>
<td>
5678
</td>
<td
class="text-center"
>
<span>
<i
class="fa fa-lg fa-check-circle text-success"
title="Paired"
/>
</span>
</td>
<td
class="text-center"
>
<button
class="btn btn-primary "
type="button"
>
Unpair device
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
exports[`<Devices /> unpair should display spinner during request 1`] = `
<div>
<div
class="spinner-wrapper my-3 text-center"
>
<div
class="spinner-border"
role="status"
>
<span
class="sr-only"
/>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Netboot /> should render component 1`] = `
<div>
<h1>
Netboot
</h1>
<p>
Manage devices which can be booted from this router through network.
</p>
<div
class="spinner-wrapper my-3 text-center"
>
<div
class="spinner-border"
role="status"
>
<span
class="sr-only"
/>
</di