Merge pull request #1418 from RickCarlino/latency_panel_idea_iii

First Draft of "Network Latency" Panel, Plus Others
pull/1420/head
Rick Carlino 2019-09-09 09:34:12 -05:00 committed by GitHub
commit 2f0fae1cd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 592 additions and 143 deletions

View File

@ -0,0 +1,9 @@
import { PingDictionary } from "../../devices/connectivity/qos";
export function fakePings(): PingDictionary {
return {
"a": { kind: "timeout", start: 111, end: 423 },
"b": { kind: "pending", start: 213 },
"c": { kind: "complete", start: 319, end: 631 }
};
}

View File

@ -20,6 +20,7 @@ import {
import { ResourceName } from "farmbot";
import { fakeTimeSettings } from "../__test_support__/fake_time_settings";
import { error } from "../toast/toast";
import { fakePings } from "../__test_support__/fake_state/pings";
const FULLY_LOADED: ResourceName[] = [
"Sequence", "Regimen", "FarmEvent", "Point", "Tool", "Device"];
@ -42,6 +43,7 @@ const fakeProps = (): AppProps => {
resources: buildResourceIndex().index,
autoSync: false,
alertCount: 0,
pings: fakePings()
};
};

View File

@ -1,5 +1,4 @@
jest.mock("../util/util", () => ({
shortRevision: jest.fn(() => "ABCD"),
trim: jest.fn((s: unknown) => s),
defensiveClone: jest.fn((s: unknown) => s)
}));
@ -13,20 +12,15 @@ jest.mock("i18next", () => ({ init: jest.fn() }));
jest.mock("../routes", () => ({ attachAppToDom: { mock: "Yeah" } }));
import { stopIE } from "../util/stop_ie";
import { shortRevision } from "../util/util";
import { detectLanguage } from "../i18n";
import I from "i18next";
describe("entry file", () => {
it("Calls the expected callbacks", async () => {
console.log = jest.fn();
await import("../entry");
expect(stopIE).toHaveBeenCalled();
expect(shortRevision).toHaveBeenCalled();
expect(detectLanguage).toHaveBeenCalled();
expect(I.init).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith("ABCD");
});
});

View File

@ -31,7 +31,7 @@ import { dispatchNetworkUp, dispatchNetworkDown } from "../connectivity";
import { Session } from "../session";
import { error } from "../toast/toast";
const A_STRING = expect.any(String);
const ANY_NUMBER = expect.any(Number);
interface FakeProps {
uuid: string;
@ -67,7 +67,7 @@ describe("responseRejected", () => {
it("undefined error", async () => {
await expect(responseRejected(undefined)).rejects.toEqual(undefined);
expect(dispatchNetworkUp).not.toHaveBeenCalled();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.api", undefined, A_STRING);
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.api", ANY_NUMBER);
});
it("safe error", async () => {
@ -77,7 +77,7 @@ describe("responseRejected", () => {
};
await expect(responseRejected(safeError)).rejects.toEqual(safeError);
expect(dispatchNetworkDown).not.toHaveBeenCalled();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.api", undefined, A_STRING);
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.api", ANY_NUMBER);
});
it("handles 500", async () => {

View File

@ -29,6 +29,7 @@ import { ResourceIndex } from "./resources/interfaces";
import { isBotOnline } from "./devices/must_be_online";
import { getStatus } from "./connectivity/reducer_support";
import { getAllAlerts } from "./messages/state_to_props";
import { PingDictionary } from "./devices/connectivity/qos";
/** For the logger module */
init();
@ -50,6 +51,7 @@ export interface AppProps {
resources: ResourceIndex;
autoSync: boolean;
alertCount: number;
pings: PingDictionary;
}
export function mapStateToProps(props: Everything): AppProps {
@ -76,6 +78,7 @@ export function mapStateToProps(props: Everything): AppProps {
resources: props.resources.index,
autoSync: !!(fbosConfig && fbosConfig.auto_sync),
alertCount: getAllAlerts(props.resources).length,
pings: props.bot.connectivity.pings
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -133,7 +136,8 @@ export class App extends React.Component<AppProps, {}> {
tour={this.props.tour}
autoSync={this.props.autoSync}
alertCount={this.props.alertCount}
device={getDeviceAccountSettings(this.props.resources)} />}
device={getDeviceAccountSettings(this.props.resources)}
pings={this.props.pings} />}
{syncLoaded && this.props.children}
{!(["controls", "account", "regimens"].includes(currentPage)) &&
<ControlsPopup

View File

@ -42,7 +42,8 @@ import { MessageType } from "../../../sequences/interfaces";
import { FbjsEventName } from "farmbot/dist/constants";
import { info, error, success, warning, fun, busy } from "../../../toast/toast";
const A_STRING = expect.any(String);
const ANY_NUMBER = expect.any(Number);
describe("readStatus()", () => {
it("forces a read_status request to FarmBot", () => {
readStatus();
@ -160,9 +161,9 @@ describe("initLog", () => {
describe("bothUp()", () => {
it("marks MQTT and API as up", () => {
bothUp("tests");
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", undefined, A_STRING);
expect(dispatchNetworkUp).toHaveBeenCalledWith("bot.mqtt", undefined, A_STRING);
bothUp();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(dispatchNetworkUp).toHaveBeenCalledWith("bot.mqtt", ANY_NUMBER);
});
});
@ -170,7 +171,7 @@ describe("onOffline", () => {
it("tells the app MQTT is down", () => {
jest.resetAllMocks();
onOffline();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", undefined, A_STRING);
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(error).toHaveBeenCalledWith(Content.MQTT_DISCONNECTED);
});
});
@ -179,7 +180,7 @@ describe("onOnline", () => {
it("tells the app MQTT is up", () => {
jest.resetAllMocks();
onOnline();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", undefined, A_STRING);
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
});
});
@ -205,13 +206,14 @@ describe("onSent", () => {
it("marks MQTT as up", () => {
jest.resetAllMocks();
onSent({ connected: true })();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", undefined, A_STRING);
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
});
it("marks MQTT as down", () => {
jest.resetAllMocks();
onSent({ connected: false })();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", undefined, A_STRING);
expect(dispatchNetworkDown)
.toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
});
});
@ -235,7 +237,8 @@ describe("onLogs", () => {
log.message = "bot xyz is offline";
fn(log);
globalQueue.maybeWork();
expect(dispatchNetworkDown).toHaveBeenCalledWith("bot.mqtt", undefined, A_STRING);
expect(dispatchNetworkDown)
.toHaveBeenCalledWith("bot.mqtt", ANY_NUMBER);
});
it("handles log fields correctly", () => {

View File

@ -1,43 +1,81 @@
const mockRedux = { store: { dispatch: jest.fn() } };
jest.mock("../../redux/store", () => mockRedux);
jest.mock("../../redux/store", () => {
return {
store: {
dispatch: jest.fn(),
getState: jest.fn((): DeepPartial<Everything> => ({
bot: {
connectivity: {
pings: {
"already_complete": {
kind: "complete",
start: 1,
end: 2
}
}
}
}
}))
}
};
});
jest.mock("../auto_sync_handle_inbound", () => ({ handleInbound: jest.fn() }));
import { dispatchNetworkUp, dispatchNetworkDown } from "../index";
import { dispatchNetworkUp, dispatchNetworkDown, dispatchQosStart, networkUptimeThrottleStats } from "../index";
import { networkUp, networkDown } from "../actions";
import { GetState } from "../../redux/interfaces";
import { autoSync, routeMqttData } from "../auto_sync";
import { handleInbound } from "../auto_sync_handle_inbound";
import { store } from "../../redux/store";
import { DeepPartial } from "redux";
import { Everything } from "../../interfaces";
import { now } from "../../devices/connectivity/qos";
const NOW = new Date();
const SHORT_TIME_LATER = new Date(NOW.getTime() + 500).getTime();
const LONGER_TIME_LATER = new Date(NOW.getTime() + 5000).getTime();
const resetStats = () => {
networkUptimeThrottleStats["user.api"] = 0;
networkUptimeThrottleStats["user.mqtt"] = 0;
networkUptimeThrottleStats["bot.mqtt"] = 0;
};
describe("dispatchNetworkUp", () => {
const NOW_UP = networkUp("bot.mqtt", NOW.getTime(), "tests");
const LATER_UP = networkUp("bot.mqtt", LONGER_TIME_LATER, "tests");
const NOW_UP = networkUp("bot.mqtt", NOW.getTime());
const LATER_UP = networkUp("bot.mqtt", LONGER_TIME_LATER);
it("calls redux directly", () => {
dispatchNetworkUp("bot.mqtt", NOW.getTime(), "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(NOW_UP);
dispatchNetworkUp("bot.mqtt", SHORT_TIME_LATER, "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(NOW_UP);
dispatchNetworkUp("bot.mqtt", LONGER_TIME_LATER, "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(LATER_UP);
dispatchNetworkUp("bot.mqtt", NOW.getTime());
expect(store.dispatch).toHaveBeenLastCalledWith(NOW_UP);
dispatchNetworkUp("bot.mqtt", SHORT_TIME_LATER);
expect(store.dispatch).toHaveBeenLastCalledWith(NOW_UP);
dispatchNetworkUp("bot.mqtt", LONGER_TIME_LATER);
expect(store.dispatch).toHaveBeenLastCalledWith(LATER_UP);
});
});
describe("dispatchNetworkDown", () => {
const NOW_DOWN = networkDown("user.api", NOW.getTime(), "tests");
const LATER_DOWN = networkDown("user.api", LONGER_TIME_LATER, "tests");
const NOW_DOWN = networkDown("user.api", NOW.getTime());
const LATER_DOWN = networkDown("user.api", LONGER_TIME_LATER);
beforeEach(resetStats);
it("calls redux directly", () => {
dispatchNetworkDown("user.api", NOW.getTime(), "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(NOW_DOWN);
dispatchNetworkDown("user.api", SHORT_TIME_LATER, "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(NOW_DOWN);
dispatchNetworkDown("user.api", LONGER_TIME_LATER, "tests");
expect(mockRedux.store.dispatch).toHaveBeenLastCalledWith(LATER_DOWN);
dispatchNetworkDown("user.api", NOW.getTime());
expect(store.dispatch).toHaveBeenLastCalledWith(NOW_DOWN);
dispatchNetworkDown("user.api", SHORT_TIME_LATER);
expect(store.dispatch).toHaveBeenLastCalledWith(NOW_DOWN);
dispatchNetworkDown("user.api", LONGER_TIME_LATER);
expect(store.dispatch).toHaveBeenLastCalledWith(LATER_DOWN);
});
it("does not falsely mark network of being down", () => {
// This test uses mocked state.
// Please see `jest.mock` calls above.
dispatchNetworkDown("bot.mqtt", now(), "already_complete");
expect(store.dispatch).not.toHaveBeenCalled();
resetStats();
dispatchNetworkDown("bot.mqtt", now(), "not_complete");
expect(store.dispatch).toHaveBeenCalled();
});
});
@ -52,3 +90,12 @@ describe("autoSync", () => {
expect(handleInbound).toHaveBeenCalledWith(dispatch, getState, rmd);
});
});
describe("dispatchQosStart", () => {
it("dispatches when a QoS ping is starting", () => {
const id = "hello";
dispatchQosStart(id);
expect(store.dispatch)
.toHaveBeenCalledWith({ type: "START_QOS_PING", payload: { id } });
});
});

View File

@ -1,8 +1,9 @@
jest.mock("../index", () => ({
dispatchNetworkDown: jest.fn(),
dispatchNetworkUp: jest.fn()
dispatchNetworkUp: jest.fn(),
dispatchQosStart: jest.fn()
}));
const ANY_NUMBER = expect.any(Number);
const mockTimestamp = 0;
jest.mock("../../util", () => ({ timestamp: () => mockTimestamp }));
@ -49,14 +50,14 @@ function fakeBot(): Farmbot {
function expectStale() {
expect(dispatchNetworkDown)
.toHaveBeenCalledWith("bot.mqtt", undefined, expect.any(String));
.toHaveBeenCalledWith("bot.mqtt", ANY_NUMBER, "TESTS");
}
function expectActive() {
expect(dispatchNetworkUp)
.toHaveBeenCalledWith("bot.mqtt", undefined, expect.any(String));
.toHaveBeenCalledWith("bot.mqtt", ANY_NUMBER, "TESTS");
expect(dispatchNetworkUp)
.toHaveBeenCalledWith("user.mqtt", undefined, expect.any(String));
.toHaveBeenCalledWith("user.mqtt", ANY_NUMBER, "TESTS");
}
describe("ping util", () => {
@ -67,12 +68,12 @@ describe("ping util", () => {
});
it("marks the bot's connection to MQTT as 'stale'", () => {
markStale();
markStale("TESTS");
expectStale();
});
it("marks the bot's connection to MQTT as 'active'", () => {
markActive();
markActive("TESTS");
expectActive();
});

View File

@ -4,7 +4,7 @@ import { networkUp, networkDown } from "../actions";
describe("connectivityReducer", () => {
it("goes up", () => {
const state = connectivityReducer(DEFAULT_STATE,
networkUp("user.mqtt", undefined, "tests"));
networkUp("user.mqtt"));
expect(state).toBeDefined();
const x = state && state.uptime["user.mqtt"];
if (x) {
@ -17,7 +17,7 @@ describe("connectivityReducer", () => {
it("goes down", () => {
const state = connectivityReducer(DEFAULT_STATE,
networkDown("user.api", undefined, "tests"));
networkDown("user.api"));
const x = state && state.uptime["user.api"];
if (x) {
expect(x.state).toBe("down");

View File

@ -0,0 +1,38 @@
import { connectivityReducer, DEFAULT_STATE } from "../reducer";
import { Actions } from "../../constants";
import { networkUp, networkDown } from "../actions";
describe("connectivity reducer", () => {
const newState = () => {
const action = { type: Actions.START_QOS_PING, payload: { id: "yep" } };
return connectivityReducer(DEFAULT_STATE, action);
};
it("starts a ping", () => {
const ping = newState().pings["yep"];
if (ping) {
expect(ping.kind).toBe("pending");
} else {
fail();
}
});
it("handles an `up` QoS ping", () => {
const state = connectivityReducer(newState(), networkUp("bot.mqtt", 1234, "yep"));
const { yep } = state.pings;
expect(yep).toBeTruthy();
if (yep) {
expect(yep.kind).toEqual("complete");
}
});
it("handles a `down` QoS ping", () => {
const state = connectivityReducer(newState(), networkDown("bot.mqtt", 1234, "yep"));
const { yep } = state.pings;
expect(yep).toBeTruthy();
if (yep) {
expect(yep.kind).toEqual("timeout");
}
});
});

View File

@ -5,10 +5,10 @@ import { ReduxAction } from "../redux/interfaces";
type NetChange = ReduxAction<EdgeStatus>;
const change = (state: "up" | "down") =>
(name: Edge, at = (new Date()).getTime(), why: string): NetChange => {
(name: Edge, at = (new Date()).getTime(), qosPingId?: string): NetChange => {
return {
type: Actions.NETWORK_EDGE_CHANGE,
payload: { name, status: { state, at }, why }
payload: { name, status: { state, at }, qosPingId }
};
};

View File

@ -28,7 +28,7 @@ export class BatchQueue {
store.dispatch(batchInitResources(this.queue));
}
this.clear();
bothUp("Inside BatchQueue instance");
bothUp();
}
push = (resource: TaggedLog) => {

View File

@ -28,6 +28,7 @@ import { ChannelName, MessageType } from "../sequences/interfaces";
import { DeepPartial } from "redux";
import { slowDown } from "./slow_down";
import { t } from "../i18next_wrapper";
import { now } from "../devices/connectivity/qos";
export const TITLE = () => t("New message from bot");
/** TODO: This ought to be stored in Redux. It is here because of historical
@ -95,9 +96,9 @@ export const batchInitResources =
return { type: Actions.BATCH_INIT, payload };
};
export const bothUp = (why: string) => {
dispatchNetworkUp("user.mqtt", undefined, why);
dispatchNetworkUp("bot.mqtt", undefined, why);
export const bothUp = () => {
dispatchNetworkUp("user.mqtt", now());
dispatchNetworkUp("bot.mqtt", now());
};
export function readStatus() {
@ -108,7 +109,7 @@ export function readStatus() {
}
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", undefined, "onOffline() callback");
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED));
};
@ -117,7 +118,7 @@ export const changeLastClientConnected = (bot: Farmbot) => () => {
"LAST_CLIENT_CONNECTED": JSON.stringify(new Date())
}).catch(noop); // This is internal stuff, don't alert user.
};
const setBothUp = () => bothUp("Got a status message");
const setBothUp = () => bothUp();
const legacyChecks = (getState: GetState) => {
const { controller_version } = getState().bot.hardware.informational_settings;
@ -150,13 +151,12 @@ type Client = { connected?: boolean };
export const onSent = (client: Client) => () => {
const connected = !!client.connected;
const why = `Outbound mqtt.js. client.connected = ${connected}`;
const cb = connected ? dispatchNetworkUp : dispatchNetworkDown;
cb("user.mqtt", undefined, why);
cb("user.mqtt", now());
};
export function onMalformed() {
bothUp("Got a malformed message");
bothUp();
if (!HACKY_FLAGS.alreadyToldUserAboutMalformedMsg) {
warning(t(Content.MALFORMED_MESSAGE_REC_UPGRADE));
HACKY_FLAGS.alreadyToldUserAboutMalformedMsg = true;
@ -166,11 +166,11 @@ export function onMalformed() {
export const onOnline =
() => {
success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", undefined, "MQTT.js is online");
dispatchNetworkUp("user.mqtt", now());
};
export const onReconnect =
() => warning(t("Attempting to reconnect to the message broker"),
t("Offline"), "yellow");
t("Offline"), "yellow");
export function onPublicBroadcast(payl: unknown) {
console.log(FbjsEventName.publicBroadcast, payl);

View File

@ -1,6 +1,7 @@
import { store } from "../redux/store";
import { networkUp, networkDown } from "./actions";
import { Edge } from "./interfaces";
import { Actions } from "../constants";
/* ABOUT THIS FILE: These functions allow us to mark the network as up or
down from anywhere within the app (even outside of React-Redux). I usually avoid
@ -10,28 +11,54 @@ unavoidable. */
/** throttle calls to these functions to avoid unnecessary re-paints. */
const SLOWDOWN_TIME = 1500;
const lastCalledAt: Record<Edge, number> = {
"user.api": 0, "user.mqtt": 0, "bot.mqtt": 0
export const networkUptimeThrottleStats: Record<Edge, number> = {
"user.api": 0,
"user.mqtt": 0,
"bot.mqtt": 0
};
function shouldThrottle(edge: Edge, now: number): boolean {
const then = lastCalledAt[edge];
const then = networkUptimeThrottleStats[edge];
const diff = now - then;
return diff < SLOWDOWN_TIME;
}
function bumpThrottle(edge: Edge, now: number) {
lastCalledAt[edge] = now;
networkUptimeThrottleStats[edge] = now;
}
export let dispatchNetworkUp = (edge: Edge, at = (new Date()).getTime(), why: string) => {
export const dispatchQosStart = (id: string) => {
store.dispatch({
type: Actions.START_QOS_PING,
payload: { id }
});
};
export let dispatchNetworkUp = (edge: Edge, at: number, qosPingId?: string) => {
if (shouldThrottle(edge, at)) { return; }
store.dispatch(networkUp(edge, at, why));
store.dispatch(networkUp(edge, at, qosPingId));
bumpThrottle(edge, at);
};
export let dispatchNetworkDown = (edge: Edge, at = (new Date()).getTime(), why: string) => {
const pingAlreadyComplete = (qosPingId?: string) => {
if (qosPingId) {
const ping =
store.getState().bot.connectivity.pings[qosPingId];
return (ping && ping.kind == "complete");
}
return false;
};
export let dispatchNetworkDown = (edge: Edge, at: number, qosPingId?: string) => {
if (shouldThrottle(edge, at)) { return; }
store.dispatch(networkDown(edge, at, why));
// If a ping is marked as "completed", then there
// is no way that the network is down. A common
// use case for this is the timeout callback
// in the QoS tester. The timeout always triggers,
// so we need to add a means of cancelling the
// "network down" action if the request completed
// before the timeout.
if (pingAlreadyComplete(qosPingId)) { return; }
store.dispatch(networkDown(edge, at, qosPingId));
bumpThrottle(edge, at);
};

View File

@ -1,4 +1,5 @@
import { TaggedResource } from "farmbot";
import { PingDictionary } from "../devices/connectivity/qos";
export type NetworkState = "up" | "down";
@ -11,7 +12,7 @@ export interface ConnectionStatus {
export interface EdgeStatus {
name: Edge;
status: ConnectionStatus;
why: string;
qosPingId?: string;
}
/** Name of a connection between two points. "." can be read as "to".
@ -27,7 +28,7 @@ type ConnectionRecord = Record<Edge, ConnectionStatus | undefined>;
* An `undefined` value means we don't know. */
export type ConnectionState = {
uptime: ConnectionRecord;
pings: {}
pings: PingDictionary;
};
export interface UpdateMqttData<T extends TaggedResource> {

View File

@ -11,6 +11,7 @@ import { Log } from "farmbot/dist/resources/api_resources";
import { globalQueue } from "./batch_queue";
import { isUndefined, get } from "lodash";
import { MessageType } from "../sequences/interfaces";
import { now } from "../devices/connectivity/qos";
const LEGACY_META_KEY_NAMES: (keyof Log)[] = [
"type",
@ -49,6 +50,6 @@ export const onLogs =
// TODO: Make a `bot/device_123/offline` channel.
const died =
msg.message.includes("is offline") && msg.type === MessageType.error;
died && dispatchNetworkDown("bot.mqtt", undefined, "Got offline message");
died && dispatchNetworkDown("bot.mqtt", now());
}
};

View File

@ -1,11 +1,16 @@
import { Farmbot } from "farmbot";
import { dispatchNetworkDown, dispatchNetworkUp } from "./index";
import { Farmbot, uuid } from "farmbot";
import {
dispatchNetworkDown,
dispatchNetworkUp,
dispatchQosStart
} from "./index";
import { isNumber } from "lodash";
import axios from "axios";
import { API } from "../api/index";
import { FarmBotInternalConfig } from "farmbot/dist/config";
import { now } from "../devices/connectivity/qos";
export const PING_INTERVAL = 3000;
export const PING_INTERVAL = 4000;
export const ACTIVE_THRESHOLD = PING_INTERVAL * 2;
export const LAST_IN: keyof FarmBotInternalConfig = "LAST_PING_IN";
@ -17,29 +22,34 @@ export function readPing(bot: Farmbot, direction: Direction): number | undefined
return isNumber(val) ? val : undefined;
}
export function markStale() {
dispatchNetworkDown("bot.mqtt", undefined, "markStale()");
export function markStale(qosPingId: string) {
dispatchNetworkDown("bot.mqtt", now(), qosPingId);
}
export function markActive() {
dispatchNetworkUp("user.mqtt", undefined, "markActive()");
dispatchNetworkUp("bot.mqtt", undefined, "markActive()");
export function markActive(qosPingId: string) {
dispatchNetworkUp("user.mqtt", now(), qosPingId);
dispatchNetworkUp("bot.mqtt", now(), qosPingId);
}
export function isInactive(last: number, now: number): boolean {
return last ? (now - last) > ACTIVE_THRESHOLD : true;
export function isInactive(last: number, now_: number): boolean {
return last ? (now_ - last) > ACTIVE_THRESHOLD : true;
}
export function sendOutboundPing(bot: Farmbot) {
bot.ping().then(markActive, markStale);
const id = uuid();
const ok = () => markActive(id);
const no = () => markStale(id);
dispatchQosStart(id);
bot.ping().then(ok, no);
}
export function startPinging(bot: Farmbot) {
sendOutboundPing(bot);
setInterval(() => sendOutboundPing(bot), PING_INTERVAL);
}
export function pingAPI() {
const ok = () => dispatchNetworkUp("user.api", undefined, "pingApi OK");
const no = () => dispatchNetworkDown("user.api", undefined, "pingApi NO");
const ok = () => dispatchNetworkUp("user.api", now());
const no = () => dispatchNetworkDown("user.api", now());
return axios.get(API.current.devicePath).then(ok, no);
}

View File

@ -5,6 +5,7 @@ import { computeBestTime } from "./reducer_support";
import { TaggedDevice } from "farmbot";
import { SyncBodyContents } from "../sync/actions";
import { arrayUnwrap } from "../resources/util";
import { startPing, completePing, failPing } from "../devices/connectivity/qos";
export const DEFAULT_STATE: ConnectionState = {
uptime: {
@ -18,7 +19,22 @@ export const DEFAULT_STATE: ConnectionState = {
export let connectivityReducer =
generateReducer<ConnectionState>(DEFAULT_STATE)
.add<{ id: string }>(Actions.START_QOS_PING, (s, { payload }) => {
return {
...s,
pings: startPing(s.pings, payload.id)
};
})
.add<EdgeStatus>(Actions.NETWORK_EDGE_CHANGE, (s, { payload }) => {
const { qosPingId, status } = payload;
if (qosPingId) {
if (status.state == "up") {
s.pings = completePing(s.pings, qosPingId, status.at);
} else {
s.pings = failPing(s.pings, qosPingId);
}
}
s.uptime[payload.name] = payload.status;
return s;
})
@ -33,6 +49,7 @@ export let connectivityReducer =
type Keys = (keyof ConnectionState["uptime"])[];
const keys: Keys = ["bot.mqtt", "user.mqtt", "user.api"];
keys.map(x => (s.uptime[x] = undefined));
s.pings = {};
return s;
});

View File

@ -754,7 +754,7 @@ export namespace Content {
trim(`Press "+" to add a point to your garden.`);
export const NO_GROUPS =
trim(`Press "+" to add a point group.`);
trim(`Press "+" to add a group.`);
export const ENTER_CROP_SEARCH_TERM =
trim(`Search for a crop to add to your garden.`);
@ -994,5 +994,6 @@ export enum Actions {
// Network
NETWORK_EDGE_CHANGE = "NETWORK_EDGE_CHANGE",
RESET_NETWORK = "RESET_NETWORK",
SET_CONSISTENCY = "SET_CONSISTENCY"
SET_CONSISTENCY = "SET_CONSISTENCY",
START_QOS_PING = "START_QOS_PING"
}

View File

@ -882,9 +882,9 @@ ul {
a {
color: $panel_blue;
}
.empty-state-graphic {
filter: hue-rotate(60deg) saturate(0.6);
}
// .empty-state-graphic {
// filter: hue-rotate(60deg) saturate(0.6);
// }
}
}

View File

@ -3,7 +3,6 @@ jest.mock("../../util/stop_ie", () => ({
}));
jest.mock("../../util", () => ({
shortRevision: jest.fn(),
attachToRoot: jest.fn()
}));
@ -16,7 +15,7 @@ jest.mock("i18next", () => ({
}));
import { stopIE } from "../../util/stop_ie";
import { shortRevision, attachToRoot } from "../../util";
import { attachToRoot } from "../../util";
import { detectLanguage } from "../../i18n";
import { DemoIframe } from "../demo_iframe";
import I from "i18next";
@ -25,7 +24,6 @@ describe("DemoIframe loader", () => {
it("calls expected callbacks", (done) => {
import("../index").then(() => {
expect(stopIE).toHaveBeenCalled();
expect(shortRevision).toHaveBeenCalled();
expect(detectLanguage).toHaveBeenCalled();
expect(I.init).toHaveBeenCalled();
expect(attachToRoot).toHaveBeenCalledWith(DemoIframe);

View File

@ -1,5 +1,5 @@
import { detectLanguage } from "../i18n";
import { shortRevision, attachToRoot } from "../util";
import { attachToRoot } from "../util";
import { stopIE } from "../util/stop_ie";
import I from "i18next";
import { DemoIframe } from "./demo_iframe";
@ -8,7 +8,6 @@ import { DemoIframe } from "./demo_iframe";
stopIE();
console.log(shortRevision());
const doAttach = () => attachToRoot(DemoIframe);
const loadDemo =
(config: I.InitOptions) => I.init(config, doAttach);

View File

@ -111,17 +111,17 @@ describe("botReducer", () => {
step1.statusStash = "booting";
step1.hardware.informational_settings.sync_status = "synced";
const step2 = botReducer(step1, networkDown("bot.mqtt", undefined, "tests"));
const step2 = botReducer(step1, networkDown("bot.mqtt"));
expect(step2.statusStash)
.toBe(step1.hardware.informational_settings.sync_status);
expect(step2.hardware.informational_settings.sync_status).toBeUndefined();
const step3 = botReducer(step1, networkDown("bot.mqtt", undefined, "tests"));
const step3 = botReducer(step1, networkDown("bot.mqtt"));
expect(step3.statusStash)
.toBe(step1.hardware.informational_settings.sync_status);
expect(step3.hardware.informational_settings.sync_status).toBeUndefined();
const step4 = botReducer(step3, networkUp("bot.mqtt", undefined, "tests"));
const step4 = botReducer(step3, networkUp("bot.mqtt"));
expect(step4.hardware.informational_settings.sync_status)
.toBe(step3.statusStash);
});

View File

@ -55,7 +55,7 @@ describe("<FbosDetails/>", () => {
"Firmware", "fakeFirmware",
"Firmware commit", "fakeFwCo",
"FAKETARGET CPU temperature", "48.3", "C",
"WiFi Strength", "-49dBm",
"WiFi strength", "-49dBm",
"Beta release Opt-In",
"Uptime", "0 seconds",
"Memory usage", "0MB",
@ -117,7 +117,7 @@ describe("<FbosDetails/>", () => {
const p = fakeProps();
p.botInfoSettings.wifi_level = undefined;
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text()).toContain("WiFi Strength: N/A");
expect(wrapper.text()).toContain("WiFi strength: N/A");
expect(wrapper.text()).not.toContain("dBm");
});

View File

@ -63,7 +63,7 @@ export function WiFiStrengthDisplay(
const percentString = `${wifiStrengthPercent || percent}%`;
return <div className="wifi-strength-display">
<p>
<b>{t("WiFi Strength")}: </b>
<b>{t("WiFi strength")}: </b>
{wifiStrength ? dbString : "N/A"}
</p>
{wifiStrength &&

View File

@ -4,6 +4,7 @@ import { Connectivity, ConnectivityProps } from "../connectivity";
import { bot } from "../../../__test_support__/fake_state/bot";
import { StatusRowProps } from "../connectivity_row";
import { fill } from "lodash";
import { fakePings } from "../../../__test_support__/fake_state/pings";
describe("<Connectivity />", () => {
const statusRow = {
@ -26,6 +27,7 @@ describe("<Connectivity />", () => {
bot,
rowData,
flags,
pings: fakePings()
});
it("sets hovered connection", () => {

View File

@ -7,6 +7,7 @@ import { SpecialStatus } from "farmbot";
import { bot } from "../../../__test_support__/fake_state/bot";
import { fakeDevice } from "../../../__test_support__/resource_index_builder";
import { resetConnectionInfo } from "../../actions";
import { fakePings } from "../../../__test_support__/fake_state/pings";
describe("<ConnectivityPanel/>", () => {
const fakeProps = (): ConnectivityPanel["props"] => ({
@ -14,6 +15,7 @@ describe("<ConnectivityPanel/>", () => {
dispatch: jest.fn(),
deviceAccount: fakeDevice(),
status: SpecialStatus.SAVED,
pings: fakePings()
});
it("renders the default use case", () => {

View File

@ -0,0 +1,87 @@
import {
calculateLatency,
calculatePingLoss,
completePing,
startPing,
failPing,
PingDictionary
} from "../qos";
import {
fakePings
} from "../../../__test_support__/fake_state/pings";
describe("QoS helpers", () => {
it("calculates latency", () => {
const report = calculateLatency({
"a": { kind: "timeout", start: 111, end: 423 },
"b": { kind: "pending", start: 213 },
"c": { kind: "complete", start: 319, end: 631 },
"d": { kind: "complete", start: 111, end: 423 },
"e": { kind: "complete", start: 136, end: 213 },
"f": { kind: "complete", start: 319, end: 631 },
});
expect(report.best).toEqual(77);
expect(report.worst).toEqual(312);
expect(report.average).toEqual(253);
expect(report.total).toEqual(4);
});
it("returns 0 when latency can't be calculated", () => {
const report = calculateLatency({});
expect(report.best).toEqual(0);
expect(report.worst).toEqual(0);
expect(report.average).toEqual(0);
expect(report.total).toEqual(1);
});
it("calculates ping loss", () => {
const report = calculatePingLoss(fakePings());
expect(report.total).toEqual(3);
expect(report.complete).toEqual(1);
expect(report.pending).toEqual(1);
expect(report.complete).toEqual(1);
});
it("marks a ping as complete", () => {
const KEY = "b";
const state = fakePings();
const before = state[KEY];
const nextState = completePing(state, KEY);
const after = nextState[KEY];
expect(before && before.kind).toEqual("pending");
expect(after && after.kind).toEqual("complete");
});
it("does not mark pings as complete twice", () => {
const state: PingDictionary = {
"x": { kind: "complete", start: 319, end: 631 },
};
const nextState = completePing(state, "x");
expect(nextState).toBe(state); // No, not "toEqual"
});
it("starts a ping", () => {
const state = fakePings();
const nextState = startPing(state, "x");
expect(state["x"]).toBeFalsy();
expect(nextState["x"]).toBeTruthy();
});
it("fails a ping", () => {
const state = fakePings();
state["x"] = { kind: "pending", start: 1 };
const nextState = failPing(state, "x");
const after = nextState["x"];
expect(after && after.kind).toEqual("timeout");
});
it("skips pings that don't need to be failed", () => {
const state: PingDictionary = {
"x": { kind: "complete", start: 319, end: 631 },
};
const nextState = failPing(state, "x");
expect(nextState).toBe(state); // No, not "toEqual"
});
});

View File

@ -8,11 +8,14 @@ import {
ChipTemperatureDisplay, WiFiStrengthDisplay, VoltageDisplay
} from "../components/fbos_settings/fbos_details";
import { t } from "../../i18next_wrapper";
import { QosPanel } from "./qos_panel";
import { PingDictionary } from "./qos";
export interface ConnectivityProps {
bot: BotState;
rowData: StatusRowProps[];
flags: DiagnosisProps;
pings: PingDictionary;
}
interface ConnectivityState {
@ -42,6 +45,7 @@ export class Connectivity
<WiFiStrengthDisplay wifiStrength={wifi_level} />
<VoltageDisplay throttled={throttled} />
</div>
<QosPanel pings={this.props.pings} />
</Col>
<Col md={12} lg={8}>
<ConnectivityRow from={t("from")} to={t("to")} header={true} />

View File

@ -8,12 +8,14 @@ import { Connectivity } from "./connectivity";
import { BotState } from "../interfaces";
import { connectivityData } from "./generate_data";
import { resetConnectionInfo } from "../actions";
import { PingDictionary } from "./qos";
interface Props {
status: SpecialStatus;
dispatch: Function;
bot: BotState;
deviceAccount: TaggedDevice;
pings: PingDictionary;
}
export class ConnectivityPanel extends React.Component<Props, {}> {
@ -39,7 +41,8 @@ export class ConnectivityPanel extends React.Component<Props, {}> {
<Connectivity
bot={this.props.bot}
rowData={this.data.rowData}
flags={this.data.flags} />
flags={this.data.flags}
pings={this.props.pings} />
</WidgetBody>
</Widget>;
}

View File

@ -0,0 +1,105 @@
import { betterCompact } from "../../util";
interface Pending {
kind: "pending";
start: number;
}
interface Timeout {
kind: "timeout";
start: number;
end: number;
}
interface Complete {
kind: "complete";
start: number;
end: number;
}
export type Ping = Complete | Pending | Timeout;
export type PingDictionary = Record<string, Ping | undefined>;
export const now = () => (new Date()).getTime();
export const startPing =
(s: PingDictionary, id: string, start = now()): PingDictionary => {
return { ...s, [id]: { kind: "pending", start } };
};
export const failPing =
(s: PingDictionary, id: string, end = now()): PingDictionary => {
const failure = s[id];
if (failure && failure.kind != "complete") {
const nextFailure: Timeout = {
kind: "timeout",
start: failure.start,
end
};
return { ...s, [id]: nextFailure };
}
return s;
};
export const completePing =
(s: PingDictionary, id: string, end = now()): PingDictionary => {
const failure = s[id];
if (failure && failure.kind == "pending") {
return {
...s,
[id]: {
kind: "complete",
start: failure.start,
end
}
};
}
return s;
};
type PingLossReport = Record<Ping["kind"] | "total", number>;
const getAll = (s: PingDictionary) => betterCompact(Object.values(s));
export const calculatePingLoss = (s: PingDictionary): PingLossReport => {
const all = getAll(s);
const report: PingLossReport = {
complete: 0,
pending: 0,
timeout: 0,
total: 0,
};
all.map(p => report[p.kind] += 1);
report.total = all.length;
return report;
};
interface LatencyReport {
best: number;
worst: number;
average: number;
total: number;
}
const mapper = (p: Ping) => (p.kind === "complete") ?
p.end - p.start : undefined;
export const calculateLatency =
(s: PingDictionary): LatencyReport => {
let latency: number[] =
betterCompact(getAll(s).map(mapper));
// Prevents "Infinity" from showing up in UI
// when the app is loading or the bot is 100%
// offline:
if (latency.length == 0) { latency = [0]; }
const average = Math.round(latency.reduce((a, b) => a + b, 0) / latency.length);
return {
best: Math.min(...latency),
worst: Math.max(...latency),
average,
total: latency.length
};
};

View File

@ -0,0 +1,69 @@
import {
calculateLatency,
calculatePingLoss,
PingDictionary,
} from "./qos";
import React from "react";
import { t } from "../../i18next_wrapper";
interface Props {
pings: PingDictionary;
}
interface KeyValProps {
k: string;
v: number | string;
}
const NA = "---";
const MS = "ms";
const PCT = "%";
const NONE = "";
function Row({ k, v }: KeyValProps) {
return <p>
<b>{t(k)}: </b>
<span>{v}</span>
</p>;
}
const pct = (n: string | number, unit: string): string => {
if (n) {
return `${n} ${unit}`;
} else {
return NA;
}
};
export class QosPanel extends React.Component<Props, {}> {
get pingState(): PingDictionary {
return this.props.pings;
}
get latencyReport() {
return calculateLatency(this.pingState);
}
get qualityReport() {
return calculatePingLoss(this.pingState);
}
render() {
const r = { ...this.latencyReport, ...this.qualityReport };
const errorRateDecimal = ((r.complete) / r.total);
const errorRate = Math.round(100 * errorRateDecimal).toFixed(0);
return <div className="fbos-info">
<label>{t("Network Quality")}</label>
<div className="chip-temp-display">
<Row k="Percent OK" v={pct(errorRate, PCT)} />
<Row k="Pings sent" v={pct(r.total, NONE)} />
<Row k="Pings received" v={pct(r.complete, NONE)} />
<Row k="Best time" v={pct(r.best, MS)} />
<Row k="Worst time" v={pct(r.worst, MS)} />
<Row k="Average time" v={pct(r.average, MS)} />
</div>
</div>;
}
}

View File

@ -4,13 +4,10 @@
*
* Try to keep this file light. */
import { detectLanguage } from "./i18n";
import { shortRevision } from "./util";
import { stopIE } from "./util/stop_ie";
import { attachAppToDom } from "./routes";
import I from "i18next";
stopIE();
console.log(shortRevision());
detectLanguage().then(config => I.init(config, attachAppToDom));

View File

@ -87,25 +87,37 @@ export function DesignerNavTabs(props: { hidden?: boolean }) {
return <div className={`panel-nav ${TAB_COLOR[tab]}-panel ${hidden}`}>
<div className="panel-tabs">
<NavTab panel={Panel.Map}
linkTo={"/app/designer"} title={t("Map")} />
<NavTab panel={Panel.Plants}
linkTo={"/app/designer/plants"} title={t("Plants")} />
<NavTab panel={Panel.FarmEvents}
linkTo={"/app/designer/events"} title={t("Events")} />
{DevSettings.futureFeaturesEnabled() &&
<NavTab panel={Panel.SavedGardens}
linkTo={"/app/designer/saved_gardens"} title={t("Gardens")} />}
{DevSettings.futureFeaturesEnabled() &&
<NavTab panel={Panel.Points}
linkTo={"/app/designer/points"} title={t("Points")} />}
{DevSettings.futureFeaturesEnabled() &&
<NavTab panel={Panel.Groups}
linkTo={"/app/designer/groups"} title={t("Groups")} />}
{DevSettings.futureFeaturesEnabled() &&
<NavTab panel={Panel.Tools}
linkTo={"/app/designer/tools"} title={t("Tools")} />}
<NavTab panel={Panel.Settings} icon={"fa fa-gear"}
linkTo={"/app/designer/settings"} title={t("Settings")} />
linkTo={"/app/designer"}
title={t("Map")} />
<NavTab
panel={Panel.Plants}
linkTo={"/app/designer/plants"}
title={t("Plants")} />
{DevSettings.futureFeaturesEnabled() && <NavTab
panel={Panel.Groups}
linkTo={"/app/designer/groups"}
title={t("Groups")} />}
{DevSettings.futureFeaturesEnabled() && <NavTab
panel={Panel.SavedGardens}
linkTo={"/app/designer/saved_gardens"}
title={t("Gardens")} />}
<NavTab
panel={Panel.FarmEvents}
linkTo={"/app/designer/events"}
title={t("Events")} />
{DevSettings.futureFeaturesEnabled() && <NavTab
panel={Panel.Points}
linkTo={"/app/designer/points"}
title={t("Points")} />}
{DevSettings.futureFeaturesEnabled() && <NavTab
panel={Panel.Tools}
linkTo={"/app/designer/tools"}
title={t("Tools")} />}
<NavTab
panel={Panel.Settings}
icon={"fa fa-gear"}
linkTo={"/app/designer/settings"}
title={t("Settings")} />
</div>
</div>;
}

View File

@ -7,9 +7,11 @@ jest.mock("../../../history", () => ({
import React from "react";
import { mount, shallow } from "enzyme";
import { GroupListPanel, GroupListPanelProps } from "../group_list_panel";
import { GroupListPanel, GroupListPanelProps, mapStateToProps } from "../group_list_panel";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import { history } from "../../../history";
import { fakeState } from "../../../__test_support__/fake_state";
import { buildResourceIndex } from "../../../__test_support__/resource_index_builder";
describe("<GroupListPanel />", () => {
const fakeProps = (): GroupListPanelProps => {
@ -51,4 +53,13 @@ describe("<GroupListPanel />", () => {
const wrapper = mount(<GroupListPanel {...p} />);
expect(wrapper.text().toLowerCase()).toContain("no groups yet");
});
it("maps state to props", () => {
const state = fakeState();
const group = fakePointGroup();
const resources = buildResourceIndex([group]);
state.resources = resources;
const x = mapStateToProps(state);
expect(x.groups).toContain(group);
});
});

View File

@ -22,7 +22,7 @@ interface State {
searchTerm: string;
}
function mapStateToProps(props: Everything): GroupListPanelProps {
export function mapStateToProps(props: Everything): GroupListPanelProps {
const groups =
findAll<TaggedPointGroup>(props.resources.index, "PointGroup");
return { groups, dispatch: props.dispatch };
@ -56,7 +56,7 @@ export class GroupListPanel extends React.Component<GroupListPanelProps, State>
title={t("No groups yet.")}
text={t(Content.NO_GROUPS)}
colorScheme="groups"
graphic={EmptyStateGraphic.plants}>
graphic={EmptyStateGraphic.no_groups}>
{this.props.groups
.filter(p => p.body.name.toLowerCase()
.includes(this.state.searchTerm.toLowerCase()))

View File

@ -10,9 +10,10 @@ import { Session } from "./session";
import { get } from "lodash";
import { t } from "./i18next_wrapper";
import { error } from "./toast/toast";
import { now } from "./devices/connectivity/qos";
export function responseFulfilled(input: AxiosResponse): AxiosResponse {
dispatchNetworkUp("user.api", undefined, "responseFulfilled()");
dispatchNetworkUp("user.api", now());
return input;
}
@ -26,7 +27,7 @@ export const isLocalRequest = (x: SafeError) =>
let ONLY_ONCE = true;
export function responseRejected(x: SafeError | undefined) {
if (x && isSafeError(x)) {
dispatchNetworkUp("user.api", undefined, "isSafeError() REST error");
dispatchNetworkUp("user.api", now());
const a = ![451, 401, 422].includes(x.response.status);
const b = x.response.status > 399;
// Openfarm API was sending too many 404's.
@ -57,7 +58,7 @@ export function responseRejected(x: SafeError | undefined) {
}
return Promise.reject(x);
} else {
dispatchNetworkDown("user.api", undefined, "responseRejected");
dispatchNetworkDown("user.api", now());
return Promise.reject(x);
}
}

View File

@ -11,6 +11,7 @@ import { NavBarProps } from "../interfaces";
import { fakeDevice } from "../../__test_support__/resource_index_builder";
import { maybeSetTimezone } from "../../devices/timezones/guess_timezone";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { fakePings } from "../../__test_support__/fake_state/pings";
describe("NavBar", () => {
const fakeProps = (): NavBarProps => ({
@ -25,6 +26,7 @@ describe("NavBar", () => {
device: fakeDevice(),
autoSync: false,
alertCount: 0,
pings: fakePings()
});
it("has correct parent classname", () => {

View File

@ -39,10 +39,8 @@ export const AdditionalMenu = (props: AccountMenuProps) => {
</div>
<div className="app-version">
<label>{t("VERSION")}</label>:&nbsp;
<a
href="https://github.com/FarmBot/Farmbot-Web-App"
target="_blank">
{shortRevision().slice(0, 7)}<p>{shortRevision().slice(7, 8)}</p>
<a href="https://github.com/FarmBot/Farmbot-Web-App" target="_blank">
{shortRevision().slice(0, 7)}
</a>
</div>
</div>;

View File

@ -129,7 +129,8 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
<Connectivity
bot={this.props.bot}
rowData={this.connectivityData.rowData}
flags={this.connectivityData.flags} />
flags={this.connectivityData.flags}
pings={this.props.pings} />
</Popover>
</div>
<RunTour currentTour={this.props.tour} />

View File

@ -2,6 +2,7 @@ import { BotState } from "../devices/interfaces";
import { TaggedUser, TaggedLog, TaggedDevice } from "farmbot";
import { GetWebAppConfigValue } from "../config_storage/actions";
import { TimeSettings } from "../interfaces";
import { PingDictionary } from "../devices/connectivity/qos";
export interface SyncButtonProps {
dispatch: Function;
@ -23,6 +24,7 @@ export interface NavBarProps {
device: TaggedDevice;
autoSync: boolean;
alertCount: number;
pings: PingDictionary;
}
export interface NavBarState {

View File

@ -8,6 +8,7 @@ export enum EmptyStateGraphic {
sequences = "sequences",
regimens = "regimens",
no_farm_events = "no_farm_events",
no_groups = "no_groups"
}
interface EmptyStateWrapperProps {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -108,7 +108,7 @@
"View messages.": "Se beskeder.",
"Week": "Uge",
"Weeks": "Uger",
"WiFi Strength": "WiFi Styrke",
"WiFi strength": "WiFi Styrke",
"Year": "År",
"Years": "År",
"yes": "ja"

View File

@ -852,7 +852,7 @@
"What do you need help with?": "What do you need help with?",
"What do you want to grow?": "What do you want to grow?",
"while your garden is applied.": "while your garden is applied.",
"WiFi Strength": "WiFi Strength",
"WiFi strength": "WiFi strength",
"Would you like to": "Would you like to",
"X": "X",
"X (mm)": "X (mm)",

View File

@ -979,7 +979,7 @@
"When you're finished with a message, press the x button in the top right of the card to dismiss it.": "Cuando terminas de leer un mensaje, pulsa el botón x en la esquina superior derecha de la tarjeta para descartarlo.",
"while your garden is applied.": "mientras se aplica tu jardín.",
"Widget load failed.": "La carga del widget falló.",
"WiFi Strength": "Señal WiFi",
"WiFi strength": "Señal WiFi",
"Would you like to": "¿Te gustaría",
"X": "X",
"x (mm)": "x (mm)",

View File

@ -733,7 +733,7 @@
"When you're finished with a message, press the x button in the top right of the card to dismiss it.": "Quand vous avez finit de lire un message, appuyez sur le x en haut à droite de ce dernier pour le faire disparaitre.",
"while your garden is applied.": "tant que votre potager est appliqué.",
"Widget load failed.": "Le chargement du widget a échoué.",
"WiFi Strength": "Force du WiFi",
"WiFi strength": "Force du WiFi",
"Would you like to": "Voulez-vous ",
"X": "X",
"X (mm)": "X (mm)",

View File

@ -837,7 +837,7 @@
"When enabled, FarmBot OS will periodically check for, download, and install updates automatically.": "When enabled, FarmBot OS will periodically check for, download, and install updates automatically.",
"while your garden is applied.": "while your garden is applied.",
"Widget load failed.": "Widget load failed.",
"WiFi Strength": "WiFi Strength",
"WiFi strength": "WiFi strength",
"Would you like to": "Would you like to",
"X": "X",
"X (mm)": "X (mm)",

View File

@ -837,7 +837,7 @@
"When enabled, FarmBot OS will periodically check for, download, and install updates automatically.": "When enabled, FarmBot OS will periodically check for, download, and install updates automatically.",
"while your garden is applied.": "while your garden is applied.",
"Widget load failed.": "Widget load failed.",
"WiFi Strength": "WiFi Strength",
"WiFi strength": "WiFi strength",
"Would you like to": "Would you like to",
"X": "X",
"X (mm)": "X (mm)",

View File

@ -837,7 +837,7 @@
"When enabled, FarmBot OS will periodically check for, download, and install updates automatically.": "When enabled, FarmBot OS will periodically check for, download, and install updates automatically.",
"while your garden is applied.": "while your garden is applied.",
"Widget load failed.": "Widget load failed.",
"WiFi Strength": "WiFi Strength",
"WiFi strength": "WiFi strength",
"Would you like to": "Would you like to",
"X": "X",
"X (mm)": "X (mm)",

View File

@ -589,7 +589,7 @@
"Welcome to the": "Добро пожаловать в",
"When enabled, FarmBot OS will periodically check for, download, and install updates automatically.": "Если опция включена, FarmBot OS будет периодически проверять, скачивать и устанавливать обновления.",
"When enabled, device resources such as sequences and regimens will be sent to the device automatically. This removes the need to push \"SYNC\" after making changes in the web app. Changes to running sequences and regimens while auto sync is enabled will result in instantaneous change.": "Если включено, настройки, такие как список Функций и Режимов будут автоматически отправляться роботу. Это убирает необходимость нажимать кнопку \"СИНХРОНИЗИРОВАТЬ\" после внесения изменений в Web-приложении. Изменения, сделанные в Режимах и Функциях, отправляются роботу мгновенно.",
"WiFi Strength": "Сигнал WiFi",
"WiFi strength": "Сигнал WiFi",
"Widget load failed.": "Не удалось загрузить окно.",
"Write Pin": "Записать пин",
"X (mm)": "X (мм)",

View File

@ -837,7 +837,7 @@
"When enabled, FarmBot OS will periodically check for, download, and install updates automatically.": "When enabled, FarmBot OS will periodically check for, download, and install updates automatically.",
"while your garden is applied.": "while your garden is applied.",
"Widget load failed.": "Widget load failed.",
"WiFi Strength": "WiFi Strength",
"WiFi strength": "WiFi strength",
"Would you like to": "Would you like to",
"X": "X",
"X (mm)": "X (mm)",