connection status indicator

pull/1144/head
gabrielburnworth 2019-04-09 17:45:51 -07:00
parent b42a0aee10
commit e873b9a6f4
27 changed files with 460 additions and 336 deletions

View File

@ -1,6 +1,4 @@
jest.mock("react-redux", () => ({
connect: jest.fn()
}));
jest.mock("react-redux", () => ({ connect: jest.fn() }));
let mockPath = "";
jest.mock("../history", () => ({
@ -15,12 +13,14 @@ import {
fakeUser, fakeWebAppConfig
} from "../__test_support__/fake_state/resources";
import { fakeState } from "../__test_support__/fake_state";
import { buildResourceIndex } from "../__test_support__/resource_index_builder";
import {
buildResourceIndex
} from "../__test_support__/resource_index_builder";
import { error } from "farmbot-toastr";
import { ResourceName } from "farmbot";
const FULLY_LOADED: ResourceName[] = [
"Sequence", "Regimen", "FarmEvent", "Point", "Tool"];
"Sequence", "Regimen", "FarmEvent", "Point", "Tool", "Device"];
const fakeProps = (): AppProps => {
return {
@ -37,6 +37,7 @@ const fakeProps = (): AppProps => {
animate: false,
getConfigValue: jest.fn(),
tour: undefined,
resources: buildResourceIndex().index,
};
};
@ -111,14 +112,18 @@ describe("<App />: Loading", () => {
describe("<App />: NavBar", () => {
it("displays links", () => {
const wrapper = mount(<App {...fakeProps()} />);
const p = fakeProps();
p.loaded = FULLY_LOADED;
const wrapper = mount(<App {...p} />);
expect(wrapper.text())
.toContain("Farm DesignerControlsDeviceSequencesRegimensToolsFarmware");
wrapper.unmount();
});
it("displays ticker", () => {
const wrapper = mount(<App {...fakeProps()} />);
const p = fakeProps();
p.loaded = FULLY_LOADED;
const wrapper = mount(<App {...p} />);
expect(wrapper.text()).toContain("No logs yet.");
wrapper.unmount();
});

View File

@ -1,17 +1,13 @@
import * as React from "react";
import { connect } from "react-redux";
import { init, error } from "farmbot-toastr";
import { NavBar } from "./nav";
import { Everything } from "./interfaces";
import { LoadingPlant } from "./loading_plant";
import { BotState, Xyz } from "./devices/interfaces";
import { ResourceName, TaggedUser, TaggedLog } from "farmbot";
import {
ResourceName, TaggedUser, TaggedLog
} from "farmbot";
import {
maybeFetchUser,
maybeGetTimeOffset,
maybeFetchUser, maybeGetTimeOffset, getDeviceAccountSettings
} from "./resources/selectors";
import { HotKeys } from "./hotkeys";
import { ControlsPopup } from "./controls_popup";
@ -19,12 +15,15 @@ import { Content } from "./constants";
import { validBotLocationData, validFwConfig } from "./util";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import { getWebAppConfigValue, GetWebAppConfigValue } from "./config_storage/actions";
import {
getWebAppConfigValue, GetWebAppConfigValue
} from "./config_storage/actions";
import { takeSortedLogs } from "./logs/state_to_props";
import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware";
import { getFirmwareConfig } from "./resources/getters";
import { intersection } from "lodash";
import { t } from "./i18next_wrapper";
import { ResourceIndex } from "./resources/interfaces";
/** For the logger module */
init();
@ -43,6 +42,7 @@ export interface AppProps {
animate: boolean;
getConfigValue: GetWebAppConfigValue;
tour: string | undefined;
resources: ResourceIndex;
}
export function mapStateToProps(props: Everything): AppProps {
@ -65,6 +65,7 @@ export function mapStateToProps(props: Everything): AppProps {
animate: !webAppConfigValue(BooleanSetting.disable_animations),
getConfigValue: webAppConfigValue,
tour: props.resources.consumers.help.currentTour,
resources: props.resources.index,
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -79,6 +80,7 @@ const MUST_LOAD: ResourceName[] = [
"Regimen",
"FarmEvent",
"Point",
"Device",
"Tool" // Sequence editor needs this for rendering.
];
@ -108,7 +110,7 @@ export class App extends React.Component<AppProps, {}> {
return <div className="app">
{!syncLoaded && <LoadingPlant animate={this.props.animate} />}
<HotKeys dispatch={this.props.dispatch} />
<NavBar
{syncLoaded && <NavBar
timeOffset={this.props.timeOffset}
consistent={this.props.consistent}
user={this.props.user}
@ -116,7 +118,8 @@ export class App extends React.Component<AppProps, {}> {
dispatch={this.props.dispatch}
logs={this.props.logs}
getConfigValue={this.props.getConfigValue}
tour={this.props.tour} />
tour={this.props.tour}
device={getDeviceAccountSettings(this.props.resources)} />}
{syncLoaded && this.props.children}
{!(["controls", "account", "regimens"].includes(currentPage)) &&
<ControlsPopup

View File

@ -684,6 +684,47 @@ export namespace TourContent {
trim(`Toggle various settings to customize your web app experience.`);
}
export namespace DiagnosticMessages {
export const OK = trim(`All systems nominal.`);
export const MISC = trim(`Some other issue is preventing FarmBot from
working. Please see the table above for more information.`);
export const TOTAL_BREAKAGE = trim(`There is no access to FarmBot or the
message broker. This is usually caused by outdated browsers
(Internet Explorer) or firewalls that block WebSockets on port 3002.`);
export const REMOTE_FIREWALL = trim(`FarmBot and the browser are both
connected to the internet (or have been recently). Try rebooting FarmBot
and refreshing the browser. If the issue persists, something may be
preventing FarmBot from accessing the message broker (used to communicate
with your web browser in real-time). If you are on a company or school
network, a firewall may be blocking port 5672.`);
export const WIFI_OR_CONFIG = trim(`Your browser is connected correctly,
but we have no recent record of FarmBot connecting to the internet.
This usually happens because of a bad WiFi signal in the garden, a bad
password during configuration, or a very long power outage.`);
export const NO_WS_AVAILABLE = trim(`You are either offline, using a web
browser that does not support WebSockets, or are behind a firewall that
blocks port 3002. Do not attempt to debug FarmBot hardware until you solve
this issue first. You will not be able to troubleshoot hardware issues
without a reliable browser and internet connection.`);
export const INACTIVE = trim(`FarmBot and the browser both have internet
connectivity, but we haven't seen any activity from FarmBot on the Web
App in a while. This could mean that FarmBot has not synced in a while,
which might not be a problem. If you are experiencing usability issues,
however, it could be a sign of HTTP blockage on FarmBot's local internet
connection.`);
export const ARDUINO_DISCONNECTED = trim(`Arduino is possibly unplugged.
Check the USB cable between the Raspberry Pi and the Arduino. Reboot
FarmBot after a reconnection. If the issue persists, reconfiguration
of FarmBot OS may be necessary.`);
}
export enum Actions {
// Resources

View File

@ -1,10 +1,4 @@
jest.mock("react-redux", () => ({
connect: jest.fn()
}));
jest.mock("../../devices/timezones/guess_timezone", () => ({
maybeSetTimezone: jest.fn()
}));
jest.mock("react-redux", () => ({ connect: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
@ -15,9 +9,6 @@ import {
} from "../../__test_support__/fake_state/resources";
import { Dictionary } from "farmbot";
import { Props } from "../interfaces";
import { fakeDevice } from "../../__test_support__/resource_index_builder";
import { maybeSetTimezone } from "../../devices/timezones/guess_timezone";
describe("<Controls />", () => {
const mockConfig: Dictionary<boolean> = {};
@ -35,7 +26,6 @@ describe("<Controls />", () => {
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
sensorReadings: [],
timeOffset: 0,
device: undefined
};
}
@ -79,12 +69,4 @@ describe("<Controls />", () => {
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("sensor history");
});
it("silently sets user timezone as needed", () => {
const p = fakeProps();
p.device = fakeDevice({ timezone: undefined });
mount(<Controls {...p} />);
const { dispatch, device } = p;
expect(maybeSetTimezone).toHaveBeenCalledWith(dispatch, device);
});
});

View File

@ -10,17 +10,10 @@ import { Move } from "./move/move";
import { BooleanSetting } from "../session_keys";
import { Feature } from "../devices/interfaces";
import { SensorReadings } from "./sensor_readings/sensor_readings";
import { maybeSetTimezone } from "../devices/timezones/guess_timezone";
/** Controls page. */
@connect(mapStateToProps)
export class Controls extends React.Component<Props, {}> {
componentDidMount = () => {
this.props.device &&
maybeSetTimezone(this.props.dispatch, this.props.device);
}
get arduinoBusy() {
return !!this.props.bot.hardware.informational_settings.busy;
}

View File

@ -1,5 +1,5 @@
import { BotState, Xyz, BotPosition, ShouldDisplay } from "../devices/interfaces";
import { Vector3, McuParams, TaggedDevice } from "farmbot/dist";
import { Vector3, McuParams } from "farmbot/dist";
import {
TaggedWebcamFeed,
TaggedPeripheral,
@ -21,7 +21,6 @@ export interface Props {
getWebAppConfigVal: GetWebAppConfigValue;
sensorReadings: TaggedSensorReading[];
timeOffset: number;
device: TaggedDevice | undefined;
}
export interface AxisDisplayGroupProps {

View File

@ -16,29 +16,26 @@ import { getFirmwareConfig } from "../resources/getters";
import { uniq } from "lodash";
export function mapStateToProps(props: Everything): Props {
const { mcu_params } = props.bot.hardware;
const bot2mqtt = props.bot.connectivity["bot.mqtt"];
const botToMqttStatus = bot2mqtt ? bot2mqtt.state : "down";
const device = maybeGetDevice(props.resources.index);
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const getWebAppConfigVal = getWebAppConfigValue(() => props);
const { mcu_params } = props.bot.hardware;
const device = maybeGetDevice(props.resources.index);
const installedOsVersion = determineInstalledOsVersion(props.bot, device);
const peripherals = uniq(selectAllPeripherals(props.resources.index));
const resources = props.resources;
const sensors = uniq(selectAllSensors(props.resources.index));
return {
feeds: selectAllWebcamFeeds(resources.index),
feeds: selectAllWebcamFeeds(props.resources.index),
dispatch: props.dispatch,
bot: props.bot,
peripherals,
sensors,
peripherals: uniq(selectAllPeripherals(props.resources.index)),
sensors: uniq(selectAllSensors(props.resources.index)),
botToMqttStatus,
firmwareSettings: fwConfig || mcu_params,
shouldDisplay: shouldDisplay(installedOsVersion, props.bot.minOsFeatureData),
getWebAppConfigVal,
getWebAppConfigVal: getWebAppConfigValue(() => props),
sensorReadings: selectAllSensorReadings(props.resources.index),
timeOffset: maybeGetTimeOffset(props.resources.index),
device
};
}

View File

@ -132,6 +132,29 @@ fieldset {
pointer-events: all;
}
.connectivity-popover-portal {
.bp3-transition-container {
z-index: 999;
}
.connectivity-popover {
.connectivity {
padding: 1rem;
max-width: 600px;
.row {
margin-bottom: 1rem;
}
.connectivity-diagram svg {
max-height: 200px !important;
}
.fbos-info {
@media (max-width:767px) {
display: none;
}
}
}
}
}
.connectivity-diagnosis {
h4 {
margin-left: 3rem;

View File

@ -92,49 +92,55 @@ nav {
margin-right: 0.8rem;
}
}
.app-version {
color: $white;
a {
color: $white;
.connection-status-popover {
display: inline;
.bp3-popover-wrapper {
margin: 1.85rem;
}
}
.bp3-popover-content {
position: relative;
width: 22rem;
a:not(.app-version) {
display: inline-block;
margin-bottom: 0.6rem;
.menu-popover {
display: inline;
.bp3-popover-content {
position: relative;
width: 22rem;
a:not(.app-version) {
display: inline-block;
margin-bottom: 0.6rem;
}
.app-version {
margin: 1rem -1rem -1rem;
background: $dark_gray;
color: $white;
padding: 0.5rem 0 0 1rem;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
label {
color: $white;
}
a {
color: $white;
}
p {
display: inline;
color: $gray;
font-size: 1.2rem;
}
}
}
.app-version {
margin: 1rem -1rem -1rem;
background: $dark_gray;
.bp3-overlay-content {
margin-top: 1.6rem;
color: $white;
padding: 0.5rem 0 0 1rem;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
label {
}
.bp3-popover-wrapper {
a {
color: $white;
}
p {
display: inline;
color: $gray;
font-size: 1.2rem;
.bp3-popover-arrow-fill {
fill: $dark_gray;
}
.bp3-popover-content {
background: $dark_gray;
}
}
}
.bp3-overlay-content {
margin-top: 1.6rem;
color: $white;
}
.bp3-popover-wrapper {
a {
color: $white;
}
.bp3-popover-arrow-fill {
fill: $dark_gray;
}
.bp3-popover-content {
background: $dark_gray;
}
}
}

View File

@ -2,10 +2,6 @@ jest.mock("react-redux", () => ({
connect: jest.fn()
}));
jest.mock("../actions", () => ({
resetConnectionInfo: jest.fn()
}));
import * as React from "react";
import { shallow, render } from "enzyme";
import { Devices } from "../devices";
@ -16,10 +12,9 @@ import {
fakeDevice, buildResourceIndex, FAKE_RESOURCES
} from "../../__test_support__/resource_index_builder";
import { FarmbotOsSettings } from "../components/farmbot_os_settings";
import { resetConnectionInfo } from "../actions";
describe("<Devices/>", () => {
const p = (): Props => ({
const fakeProps = (): Props => ({
userToApi: undefined,
userToMqtt: undefined,
botToMqtt: undefined,
@ -38,23 +33,22 @@ describe("<Devices/>", () => {
saveFarmwareEnv: jest.fn(),
});
it("resets connection info", () => {
const el = shallow<Devices>(<Devices {...p()} />);
const devices: Devices = el.instance();
jest.resetAllMocks();
expect(devices.props.dispatch).not.toHaveBeenCalled();
devices.refresh();
expect(devices.props.dispatch).toHaveBeenCalled();
expect(resetConnectionInfo).toHaveBeenCalled();
});
it("renders relevant panels", () => {
const el = shallow(<Devices {...p()} />);
const el = shallow(<Devices {...fakeProps()} />);
expect(el.find(FarmbotOsSettings).length).toBe(1);
});
it("Crashes when logged out", () => {
const props = p();
props.auth = undefined;
expect(() => render(<Devices {...props} />)).toThrow();
it("crashes when logged out", () => {
const p = fakeProps();
p.auth = undefined;
expect(() => render(<Devices {...p} />)).toThrow();
});
it("has correct connection status", () => {
const p = fakeProps();
p.botToMqtt = { at: "123", state: "up" };
const wrapper = shallow(<Devices {...p} />);
expect(wrapper.find(FarmbotOsSettings).props().botToMqttLastSeen)
.toEqual("123");
});
});

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { mount } from "enzyme";
import { Connectivity, ConnectivityProps } from "../connectivity";
import { bot } from "../../../__test_support__/fake_state/bot";
import { StatusRowProps } from "../connectivity_row";
import { fill } from "lodash";
describe("<Connectivity />", () => {
const statusRow = {
connectionName: "AB",
from: "A",
to: "B",
connectionStatus: false,
children: "Can't do things with stuff."
};
const rowData: StatusRowProps[] = fill(Array(5), statusRow);
const flags = {
userMQTT: false,
userAPI: false,
botMQTT: false,
botAPI: false,
botFirmware: false,
};
const fakeProps = (): ConnectivityProps => ({
bot,
rowData,
flags,
});
it("sets hovered connection", () => {
const wrapper = mount<Connectivity>(<Connectivity {...fakeProps()} />);
wrapper.find(".saucer").at(6).simulate("mouseEnter");
expect(wrapper.instance().state.hoveredConnection).toEqual("AB");
});
});

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { diagnose, Diagnosis } from "../diagnosis";
import { DiagnosticMessages } from "../diagnostic_messages";
import { diagnose, Diagnosis, DiagnosisSaucer } from "../diagnosis";
import { DiagnosticMessages } from "../../../constants";
import { mount } from "enzyme";
describe("<Diagnosis/>", () => {
@ -26,6 +26,20 @@ describe("<Diagnosis/>", () => {
});
});
describe("<DiagnosisSaucer />", () => {
it("renders green", () => {
const flags = {
userMQTT: true,
userAPI: true,
botMQTT: true,
botAPI: true,
botFirmware: true,
};
const wrapper = mount(<DiagnosisSaucer {...flags} />);
expect(wrapper.find(".saucer").hasClass("green")).toBeTruthy();
});
});
describe("diagnose()", () => {
function testDiagnosis(msg: string,
userAPI: boolean,

View File

@ -1,46 +1,33 @@
jest.mock("../../actions", () => ({ resetConnectionInfo: jest.fn() }));
import * as React from "react";
import { render, mount } from "enzyme";
import { render, shallow } from "enzyme";
import { ConnectivityPanel } from "../index";
import { StatusRowProps } from "../connectivity_row";
import { SpecialStatus } from "farmbot";
import { bot } from "../../../__test_support__/fake_state/bot";
import { fill } from "lodash";
import { fakeDevice } from "../../../__test_support__/resource_index_builder";
import { resetConnectionInfo } from "../../actions";
describe("<ConnectivityPanel/>", () => {
function test() {
const onRefresh = jest.fn();
const statusRow = {
connectionName: "AB",
from: "A",
to: "B",
connectionStatus: false,
children: "Can't do things with stuff."
};
const rowData: StatusRowProps[] = fill(Array(5), statusRow);
return {
component: <ConnectivityPanel
onRefresh={onRefresh}
rowData={rowData}
status={SpecialStatus.SAVED}
fbosInfo={bot.hardware.informational_settings}>
<p>I am a child component.</p>
</ConnectivityPanel>,
rowData: rowData
};
}
const fakeProps = (): ConnectivityPanel["props"] => ({
bot: bot,
dispatch: jest.fn(),
deviceAccount: fakeDevice(),
status: SpecialStatus.SAVED,
});
it("renders the default use case", () => {
const testcase = test();
const el = render(testcase.component);
expect(el.text()).toContain("I am a child");
expect(el.text()).toContain(testcase.rowData[0].children);
const p = fakeProps();
const wrapper = render(<ConnectivityPanel {...p} />);
["Check Again", "Connectivity"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("sets hovered connection", () => {
const testcase = test();
const el = mount<ConnectivityPanel>(testcase.component);
el.find(".saucer").last().simulate("mouseEnter");
expect(el.instance().state.hoveredConnection).toEqual("AB");
it("resets connection info", () => {
const p = fakeProps();
const wrapper = shallow<ConnectivityPanel>(<ConnectivityPanel {...p} />);
wrapper.instance().refresh();
expect(p.dispatch).toHaveBeenCalled();
expect(resetConnectionInfo).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { BotState } from "../interfaces";
import { Diagnosis, DiagnosisProps } from "./diagnosis";
import { ConnectivityRow, StatusRowProps } from "./connectivity_row";
import { Row, Col } from "../../ui";
import { ConnectivityDiagram } from "./diagram";
import {
ChipTemperatureDisplay, WiFiStrengthDisplay
} from "../components/fbos_settings/fbos_details";
import { t } from "../../i18next_wrapper";
export interface ConnectivityProps {
bot: BotState;
rowData: StatusRowProps[];
flags: DiagnosisProps;
}
interface ConnectivityState {
hoveredConnection: string | undefined;
}
export class Connectivity
extends React.Component<ConnectivityProps, ConnectivityState> {
state: ConnectivityState = { hoveredConnection: undefined };
hover = (name: string) =>
() => this.setState({ hoveredConnection: name });
render() {
const { soc_temp, wifi_level } = this.props.bot.hardware.informational_settings;
return <div className="connectivity">
<Row>
<Col md={12} lg={4}>
<ConnectivityDiagram
rowData={this.props.rowData}
hover={this.hover}
hoveredConnection={this.state.hoveredConnection} />
<div className="fbos-info">
<label>{t("Raspberry Pi Info")}</label>
<ChipTemperatureDisplay temperature={soc_temp} />
<WiFiStrengthDisplay wifiStrength={wifi_level} />
</div>
</Col>
<Col md={12} lg={8}>
<ConnectivityRow from={t("from")} to={t("to")} header={true} />
{this.props.rowData
.map((x, y) => <ConnectivityRow {...x} key={y}
hover={this.hover}
hoveredConnection={this.state.hoveredConnection} />)}
<hr style={{ marginLeft: "3rem" }} />
<Diagnosis {...this.props.flags} />
</Col>
</Row>
</div>;
}
}

View File

@ -1,5 +1,5 @@
import * as React from "react";
import { DiagnosticMessages } from "./diagnostic_messages";
import { DiagnosticMessages } from "../../constants";
import { Col, Row } from "../../ui/index";
import { bitArray } from "../../util";
import { TRUTH_TABLE } from "./truth_table";
@ -14,11 +14,20 @@ export type DiagnosisName =
export type DiagnosisProps = Record<DiagnosisName, boolean>;
export const diagnosisStatus = (props: DiagnosisProps): boolean =>
props.userMQTT && props.botAPI && props.botMQTT && props.botFirmware;
export const DiagnosisSaucer = (props: DiagnosisProps) => {
const diagnosisBoolean = diagnosisStatus(props);
const diagnosisColor = diagnosisBoolean ? "green" : "red";
const title = diagnosisBoolean ? t("Ok") : t("Error");
return <div className={"saucer active " + diagnosisColor} title={title} />;
};
export function Diagnosis(props: DiagnosisProps) {
const diagnosisStatus =
props.userMQTT && props.botAPI && props.botMQTT && props.botFirmware;
const diagnosisColor = diagnosisStatus ? "green" : "red";
const title = diagnosisStatus ? t("Ok") : t("Error");
const diagnosisBoolean = diagnosisStatus(props);
const diagnosisColor = diagnosisBoolean ? "green" : "red";
const title = diagnosisBoolean ? t("Ok") : t("Error");
return <div>
<div className={"connectivity-diagnosis"}>
<h4>{t("Diagnosis")}</h4>

View File

@ -1,44 +0,0 @@
import { trim } from "../../util";
import { t } from "../../i18next_wrapper";
export namespace DiagnosticMessages {
// "SCV good to go, sir." is also appropriate.
export const OK = t("All systems nominal.");
export const MISC = trim(t(`Some other issue is preventing FarmBot from
working. Please see the table above for more information.`));
export const TOTAL_BREAKAGE = trim(t(`There is no access to FarmBot or the
message broker. This is usually caused by outdated browsers
(Internet Explorer) or firewalls that block WebSockets on port 3002.`));
export const REMOTE_FIREWALL = trim(t(`FarmBot and the browser are both
connected to the internet (or have been recently). Try rebooting FarmBot
and refreshing the browser. If the issue persists, something may be
preventing FarmBot from accessing the message broker (used to communicate
with your web browser in real-time). If you are on a company or school
network, a firewall may be blocking port 5672.`));
export const WIFI_OR_CONFIG = trim(t(`Your browser is connected correctly,
but we have no recent record of FarmBot connecting to the internet.
This usually happens because of a bad WiFi signal in the garden, a bad
password during configuration, or a very long power outage.`));
export const NO_WS_AVAILABLE = trim(t(`You are either offline, using a web
browser that does not support WebSockets, or are behind a firewall that
blocks port 3002. Do not attempt to debug FarmBot hardware until you solve
this issue first. You will not be able to troubleshoot hardware issues
without a reliable browser and internet connection.`));
export const INACTIVE = trim(t(`FarmBot and the browser both have internet
connectivity, but we haven't seen any activity from FarmBot on the Web
App in a while. This could mean that FarmBot has not synced in a while,
which might not be a problem. If you are experiencing usability issues,
however, it could be a sign of HTTP blockage on FarmBot's local internet
connection.`));
export const ARDUINO_DISCONNECTED = trim(t(`Arduino is possibly unplugged.
Check the USB cable between the Raspberry Pi and the Arduino. Reboot
FarmBot after a reconnection. If the issue persists, reconfiguration
of FarmBot OS may be necessary.`));
}

View File

@ -0,0 +1,44 @@
import { TaggedDevice } from "farmbot";
import { BotState } from "../interfaces";
import { DiagnosisName, DiagnosisProps } from "./diagnosis";
import { StatusRowProps } from "./connectivity_row";
import {
browserToMQTT, browserToAPI, botToMQTT, botToAPI, botToFirmware
} from "./status_checks";
interface ConnectivityDataProps {
bot: BotState;
device: TaggedDevice;
}
export const connectivityData = (props: ConnectivityDataProps) => {
const fwVersion = props.bot.hardware
.informational_settings.firmware_version;
/** A record of all the things we know about connectivity right now. */
const data: Record<DiagnosisName, StatusRowProps> = {
userMQTT: browserToMQTT(props.bot.connectivity["user.mqtt"]),
userAPI: browserToAPI(props.bot.connectivity["user.api"]),
botMQTT: botToMQTT(props.bot.connectivity["bot.mqtt"]),
botAPI: botToAPI(props.device.body.last_saw_api),
botFirmware: botToFirmware(fwVersion),
};
const flags: DiagnosisProps = {
userMQTT: !!data.userMQTT.connectionStatus,
userAPI: !!data.userAPI,
botMQTT: !!data.botMQTT.connectionStatus,
botAPI: !!data.botAPI.connectionStatus,
botFirmware: !!data.botFirmware.connectionStatus,
};
/** Shuffle these around to change the ordering of the status table. */
const rowData: StatusRowProps[] = [
data.userAPI,
data.userMQTT,
data.botMQTT,
data.botAPI,
data.botFirmware,
];
return { data, flags, rowData };
};

View File

@ -1,68 +1,45 @@
import * as React from "react";
import { Widget, WidgetHeader, WidgetBody, Row, Col } from "../../ui/index";
import { ConnectivityRow, StatusRowProps } from "./connectivity_row";
import { Widget, WidgetHeader, WidgetBody } from "../../ui";
import { RetryBtn } from "./retry_btn";
import { SpecialStatus, InformationalSettings } from "farmbot";
import { ConnectivityDiagram } from "./diagram";
import { SpecialStatus, TaggedDevice } from "farmbot";
import { ToolTips } from "../../constants";
import {
ChipTemperatureDisplay, WiFiStrengthDisplay
} from "../components/fbos_settings/fbos_details";
import { t } from "../../i18next_wrapper";
import { Connectivity } from "./connectivity";
import { BotState } from "../interfaces";
import { connectivityData } from "./generate_data";
import { resetConnectionInfo } from "../actions";
interface Props {
onRefresh(): void;
rowData: StatusRowProps[];
children?: React.ReactChild;
status: SpecialStatus;
fbosInfo: InformationalSettings;
dispatch: Function;
bot: BotState;
deviceAccount: TaggedDevice;
}
interface ConnectivityState {
hoveredConnection: string | undefined;
}
export class ConnectivityPanel extends React.Component<Props, {}> {
get data() {
return connectivityData({
bot: this.props.bot, device: this.props.deviceAccount
});
}
export class ConnectivityPanel extends React.Component<Props, ConnectivityState> {
state: ConnectivityState = { hoveredConnection: undefined };
hover = (name: string) =>
() => this.setState({ hoveredConnection: name });
refresh = () => this.props.dispatch(resetConnectionInfo());
render() {
const { rowData } = this.props;
const { soc_temp, wifi_level } = this.props.fbosInfo;
return <Widget className="connectivity-widget">
<WidgetHeader
title={t("Connectivity")}
helpText={ToolTips.CONNECTIVITY}>
<RetryBtn
status={this.props.status}
onClick={this.props.onRefresh}
flags={rowData.map(x => !!x.connectionStatus)} />
onClick={this.refresh}
flags={this.data.rowData.map(x => !!x.connectionStatus)} />
</WidgetHeader>
<WidgetBody>
<Row>
<Col md={12} lg={4}>
<ConnectivityDiagram
rowData={rowData}
hover={this.hover}
hoveredConnection={this.state.hoveredConnection} />
<div className="fbos-info">
<label>{t("Raspberry Pi Info")}</label>
<ChipTemperatureDisplay temperature={soc_temp} />
<WiFiStrengthDisplay wifiStrength={wifi_level} />
</div>
</Col>
<Col md={12} lg={8}>
<ConnectivityRow from={t("from")} to={t("to")} header={true} />
{rowData
.map((x, y) => <ConnectivityRow {...x} key={y}
hover={this.hover}
hoveredConnection={this.state.hoveredConnection} />)}
<hr style={{ marginLeft: "3rem" }} />
{this.props.children}
</Col>
</Row>
<Connectivity
bot={this.props.bot}
rowData={this.data.rowData}
flags={this.data.flags} />
</WidgetBody>
</Widget>;
}

View File

@ -1,5 +1,5 @@
import { Dictionary } from "farmbot";
import { DiagnosticMessages } from "./diagnostic_messages";
import { DiagnosticMessages } from "../../constants";
// I don't like this at all.
// If anyone has a cleaner solution, I'd love to hear it.

View File

@ -5,49 +5,12 @@ import { FarmbotOsSettings } from "./components/farmbot_os_settings";
import { Page, Col, Row } from "../ui/index";
import { mapStateToProps } from "./state_to_props";
import { Props } from "./interfaces";
import { ConnectivityPanel } from "./connectivity/index";
import {
botToMQTT, botToAPI, browserToMQTT, botToFirmware, browserToAPI
} from "./connectivity/status_checks";
import { Diagnosis, DiagnosisName } from "./connectivity/diagnosis";
import { StatusRowProps } from "./connectivity/connectivity_row";
import { resetConnectionInfo } from "./actions";
import { PinBindings } from "./pin_bindings/pin_bindings";
import { selectAllDiagnosticDumps } from "../resources/selectors";
import { ConnectivityPanel } from "./connectivity";
@connect(mapStateToProps)
export class Devices extends React.Component<Props, {}> {
state = { online: navigator.onLine };
/** A record of all the things we know about connectivity right now. */
get flags(): Record<DiagnosisName, StatusRowProps> {
const fwVersion = this.props.bot.hardware
.informational_settings.firmware_version;
return {
userMQTT: browserToMQTT(this.props.userToMqtt),
userAPI: browserToAPI(this.props.userToApi),
botMQTT: botToMQTT(this.props.botToMqtt),
botAPI: botToAPI(this.props.deviceAccount.body.last_saw_api),
botFirmware: botToFirmware(fwVersion),
};
}
/** Shuffle these around to change the ordering of the status table. */
get rowData(): StatusRowProps[] {
return [
this.flags.userAPI,
this.flags.userMQTT,
this.flags.botMQTT,
this.flags.botAPI,
this.flags.botFirmware,
];
}
refresh = () => {
this.props.dispatch(resetConnectionInfo());
};
render() {
if (this.props.auth) {
const { botToMqtt } = this.props;
@ -72,16 +35,9 @@ export class Devices extends React.Component<Props, {}> {
saveFarmwareEnv={this.props.saveFarmwareEnv} />
<ConnectivityPanel
status={this.props.deviceAccount.specialStatus}
onRefresh={this.refresh}
rowData={this.rowData}
fbosInfo={this.props.bot.hardware.informational_settings}>
<Diagnosis
userAPI={!!this.flags.userAPI}
userMQTT={!!this.flags.userMQTT.connectionStatus}
botMQTT={!!this.flags.botMQTT.connectionStatus}
botAPI={!!this.flags.botAPI.connectionStatus}
botFirmware={!!this.flags.botFirmware.connectionStatus} />
</ConnectivityPanel>
bot={this.props.bot}
dispatch={this.props.dispatch}
deviceAccount={this.props.deviceAccount} />
</Col>
<Col xs={12} sm={6}>
<HardwareSettings

View File

@ -4,21 +4,23 @@ import { TimezoneSelector } from "../timezone_selector";
import { inferTimezone } from "../guess_timezone";
describe("<TimezoneSelector/>", () => {
const fakeProps = (): TimezoneSelector["props"] => ({
currentTimezone: undefined,
onUpdate: jest.fn(),
});
it("handles a dropdown selection", () => {
const props: TimezoneSelector["props"] =
({ currentTimezone: undefined, onUpdate: jest.fn() });
const instance = new TimezoneSelector(props);
const p = fakeProps();
const instance = new TimezoneSelector(p);
const ddi = { value: "UTC", label: "_" };
instance.itemSelected(ddi);
expect(props.onUpdate).toHaveBeenCalledWith(ddi.value);
expect(p.onUpdate).toHaveBeenCalledWith(ddi.value);
});
it("triggers life cycle callbacks", () => {
const props: TimezoneSelector["props"] =
({ currentTimezone: undefined, onUpdate: jest.fn() });
const el = mount<TimezoneSelector>(<TimezoneSelector {...props} />);
el.simulate("change");
// componentWillMount() triggers timezone inference:
expect(props.onUpdate).toHaveBeenCalledWith(inferTimezone(undefined));
const p = fakeProps();
const el = mount(<TimezoneSelector {...p} />);
el.mount();
expect(p.onUpdate).toHaveBeenCalledWith(inferTimezone(undefined));
});
});

View File

@ -1,10 +1,15 @@
import * as React from "react";
import { shallow } from "enzyme";
jest.mock("../../devices/timezones/guess_timezone", () => ({
maybeSetTimezone: jest.fn()
}));
import * as React from "react";
import { shallow, mount } from "enzyme";
import { NavBar } from "../index";
import { bot } from "../../__test_support__/fake_state/bot";
import { taggedUser } from "../../__test_support__/user";
import { NavBarProps } from "../interfaces";
import { fakeDevice } from "../../__test_support__/resource_index_builder";
import { maybeSetTimezone } from "../../devices/timezones/guess_timezone";
describe("NavBar", () => {
const fakeProps = (): NavBarProps => ({
@ -16,6 +21,7 @@ describe("NavBar", () => {
dispatch: jest.fn(),
getConfigValue: jest.fn(),
tour: undefined,
device: fakeDevice(),
});
it("has correct parent classname", () => {
@ -31,4 +37,12 @@ describe("NavBar", () => {
link.simulate("click");
expect(wrapper.instance().state.mobileMenuOpen).toBeFalsy();
});
it("silently sets user timezone as needed", () => {
const p = fakeProps();
p.device = fakeDevice({ timezone: undefined });
const wrapper = mount(<NavBar {...p} />);
wrapper.mount();
expect(maybeSetTimezone).toHaveBeenCalledWith(p.dispatch, p.device);
});
});

View File

@ -21,21 +21,21 @@ describe("<SyncButton/>", function () {
expect(result.hasClass("gray")).toBeTruthy();
});
it("is not gray when disconnected", () => {
it("is gray when disconnected", () => {
const p = fakeProps();
p.consistent = false;
p.bot.hardware.informational_settings.sync_status = "unknown";
const result = shallow(<SyncButton {...p} />);
expect(result.hasClass("gray")).toBeFalsy();
expect(result.hasClass("gray")).toBeTruthy();
});
it("defaults to `disconnected` and `red` when uncertain", () => {
it("defaults to `unknown` and `gray` when uncertain", () => {
const p = fakeProps();
// tslint:disable-next-line:no-any
p.bot.hardware.informational_settings.sync_status = "new" as any;
const result = shallow(<SyncButton {...p} />);
expect(result.text()).toContain("new");
expect(result.hasClass("red")).toBeTruthy();
expect(result.hasClass("gray")).toBeTruthy();
});
it("syncs when clicked", () => {

View File

@ -1,5 +1,4 @@
import * as React from "react";
import { NavBarProps, NavBarState } from "./interfaces";
import { EStopButton } from "../devices/components/e_stop_btn";
import { Session } from "../session";
@ -15,6 +14,10 @@ import { Popover, Position } from "@blueprintjs/core";
import { ErrorBoundary } from "../error_boundary";
import { RunTour } from "../help/tour";
import { t } from "../i18next_wrapper";
import { Connectivity } from "../devices/connectivity/connectivity";
import { connectivityData } from "../devices/connectivity/generate_data";
import { DiagnosisSaucer } from "../devices/connectivity/diagnosis";
import { maybeSetTimezone } from "../devices/timezones/guess_timezone";
export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
@ -24,6 +27,11 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
accountMenuOpen: false
};
componentDidMount = () => {
const { device } = this.props;
device && maybeSetTimezone(this.props.dispatch, device);
}
logout = () => Session.clear();
toggle = (name: keyof NavBarState) => () =>
@ -38,6 +46,14 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
dispatch={this.props.dispatch}
consistent={this.props.consistent} />;
}
get connectivityData() {
return connectivityData({
bot: this.props.bot,
device: this.props.device
});
}
render() {
const hasName = this.props.user && this.props.user.body.name;
@ -80,19 +96,32 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
</span>
</div>
<div className="nav-right">
<Popover
position={Position.BOTTOM_RIGHT}
isOpen={accountMenuOpen}
onClose={this.close("accountMenuOpen")}
usePortal={false}>
<div className="nav-name"
onClick={this.toggle("accountMenuOpen")}>
{firstName}
</div>
{AdditionalMenu({ logout: this.logout, close })}
</Popover>
<div className="menu-popover">
<Popover
position={Position.BOTTOM_RIGHT}
isOpen={accountMenuOpen}
onClose={this.close("accountMenuOpen")}
usePortal={false}>
<div className="nav-name"
onClick={this.toggle("accountMenuOpen")}>
{firstName}
</div>
{AdditionalMenu({ logout: this.logout, close })}
</Popover>
</div>
<EStopButton bot={this.props.bot} />
{this.syncButton()}
<div className="connection-status-popover">
<Popover position={Position.BOTTOM_RIGHT}
portalClassName={"connectivity-popover-portal"}
popoverClassName="connectivity-popover">
<DiagnosisSaucer {...this.connectivityData.flags} />
<Connectivity
bot={this.props.bot}
rowData={this.connectivityData.rowData}
flags={this.connectivityData.flags} />
</Popover>
</div>
<RunTour currentTour={this.props.tour} />
</div>
</div>

View File

@ -1,5 +1,5 @@
import { BotState } from "../devices/interfaces";
import { TaggedUser, TaggedLog } from "farmbot";
import { TaggedUser, TaggedLog, TaggedDevice } from "farmbot";
import { GetWebAppConfigValue } from "../config_storage/actions";
export interface SyncButtonProps {
@ -18,6 +18,7 @@ export interface NavBarProps {
timeOffset: number;
getConfigValue: GetWebAppConfigValue;
tour: string | undefined;
device: TaggedDevice;
}
export interface NavBarState {

View File

@ -10,9 +10,9 @@ const COLOR_MAPPING: Record<SyncStatus, string> = {
"sync_now": "yellow",
"syncing": "yellow",
"sync_error": "red",
"booting": "yellow",
"maintenance": "yellow",
"unknown": "red"
"booting": "gray",
"maintenance": "gray",
"unknown": "gray"
};
const TEXT_MAPPING: () => Record<SyncStatus, string> = () => ({
@ -20,9 +20,9 @@ const TEXT_MAPPING: () => Record<SyncStatus, string> = () => ({
"sync_now": t("SYNC NOW"),
"syncing": t("SYNCING"),
"sync_error": t("SYNC ERROR"),
"booting": t("BOOTING"),
"unknown": t("DISCONNECTED"),
"maintenance": t("MAINTENANCE DOWNTIME")
"booting": t("UNKNOWN"),
"unknown": t("UNKNOWN"),
"maintenance": t("UNKNOWN")
});
/** Animation during syncing action */
@ -31,7 +31,7 @@ const spinner = <span className="btn-spinner sync" />;
export function SyncButton({ bot, dispatch, consistent }: SyncButtonProps) {
const { sync_status } = bot.hardware.informational_settings;
const syncStatus = sync_status || "unknown";
const normalColor = COLOR_MAPPING[syncStatus] || "red";
const normalColor = COLOR_MAPPING[syncStatus] || "gray";
const color = (!consistent && (syncStatus === "sync_now"))
? "gray"
: normalColor;

View File

@ -23,17 +23,17 @@
"author": "farmbot.io",
"license": "MIT",
"dependencies": {
"@blueprintjs/core": "3.15.0",
"@blueprintjs/datetime": "3.8.0",
"@blueprintjs/core": "3.15.1",
"@blueprintjs/datetime": "3.9.0",
"@blueprintjs/select": "3.8.0",
"@types/enzyme": "3.9.1",
"@types/jest": "24.0.11",
"@types/lodash": "4.14.123",
"@types/markdown-it": "0.0.7",
"@types/moxios": "0.4.8",
"@types/node": "11.13.0",
"@types/node": "11.13.2",
"@types/promise-timeout": "1.3.0",
"@types/react": "16.8.12",
"@types/react": "16.8.13",
"@types/react-color": "3.0.0",
"@types/react-dom": "16.8.3",
"@types/react-redux": "7.0.6",
@ -42,7 +42,7 @@
"browser-speech": "1.1.1",
"coveralls": "3.0.3",
"enzyme": "3.9.0",
"enzyme-adapter-react-16": "1.11.2",
"enzyme-adapter-react-16": "1.12.1",
"farmbot": "7.0.0-rc17",
"farmbot-toastr": "1.0.3",
"i18next": "15.0.9",
@ -61,19 +61,19 @@
"react-color": "2.17.0",
"react-dom": "16.8.6",
"react-joyride": "2.0.5",
"react-redux": "6.0.1",
"react-redux": "7.0.1",
"react-test-renderer": "16.8.6",
"react-transition-group": "2.8.0",
"react-transition-group": "2.9.0",
"redux": "4.0.1",
"redux-immutable-state-invariant": "2.1.0",
"redux-thunk": "2.3.0",
"sass": "1.17.4",
"sass": "1.18.0",
"sass-lint": "1.12.1",
"takeme": "0.11.1",
"ts-jest": "24.0.1",
"ts-jest": "24.0.2",
"ts-lint": "4.5.1",
"tslint": "5.15.0",
"typescript": "3.4.2",
"typescript": "3.4.3",
"which": "1.3.1"
},
"devDependencies": {