Merge branch 'auto_sync' of https://github.com/rickcarlino/Farmbot-Web-App into auto_sync
This commit is contained in:
commit
57e4f818e8
1
Gemfile
1
Gemfile
|
@ -25,6 +25,7 @@ gem "webpack-rails"
|
|||
# vars if you wish to use them on your own servers.
|
||||
gem "rollbar"
|
||||
gem "skylight", "1.4.0"
|
||||
gem "sneakers"
|
||||
|
||||
group :development, :test do
|
||||
gem "codecov", require: false
|
||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -60,9 +60,12 @@ GEM
|
|||
tzinfo (~> 1.1)
|
||||
addressable (2.5.2)
|
||||
public_suffix (>= 2.0.2, < 4.0)
|
||||
amq-protocol (2.2.0)
|
||||
arel (8.0.0)
|
||||
bcrypt (3.1.11)
|
||||
builder (3.2.3)
|
||||
bunny (2.7.1)
|
||||
amq-protocol (>= 2.2.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
choice (0.2.0)
|
||||
|
@ -228,6 +231,9 @@ GEM
|
|||
rspec-support (~> 3.5.0)
|
||||
rspec-support (3.5.0)
|
||||
ruby-graphviz (1.2.3)
|
||||
serverengine (1.5.11)
|
||||
sigdump (~> 0.2.2)
|
||||
sigdump (0.2.4)
|
||||
simplecov (0.15.1)
|
||||
docile (~> 1.1.0)
|
||||
json (>= 1.8, < 3)
|
||||
|
@ -235,6 +241,11 @@ GEM
|
|||
simplecov-html (0.10.2)
|
||||
skylight (1.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
sneakers (2.6.0)
|
||||
bunny (~> 2.7.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
serverengine (~> 1.5.11)
|
||||
thor
|
||||
sprockets (3.7.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
|
@ -293,6 +304,7 @@ DEPENDENCIES
|
|||
simplecov
|
||||
skylight (= 1.4.0)
|
||||
smarf_doc!
|
||||
sneakers
|
||||
thin
|
||||
tzinfo
|
||||
webpack-rails
|
||||
|
@ -301,4 +313,4 @@ RUBY VERSION
|
|||
ruby 2.4.2p198
|
||||
|
||||
BUNDLED WITH
|
||||
1.15.4
|
||||
1.16.0.pre.3
|
||||
|
|
|
@ -89,7 +89,13 @@ private
|
|||
end
|
||||
|
||||
def current_device
|
||||
@current_device ||= (current_user.try(:device) || no_device)
|
||||
if @current_device
|
||||
@current_device
|
||||
else
|
||||
@current_device = (current_user.try(:device) || no_device)
|
||||
Thread.current[:device] = @current_device # Mutable state eww
|
||||
@current_device
|
||||
end
|
||||
end
|
||||
|
||||
def no_device
|
||||
|
|
|
@ -1,3 +1,44 @@
|
|||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
after_save :maybe_broadcast, on: [:create, :update]
|
||||
after_destroy :maybe_broadcast
|
||||
|
||||
DONT_BROADCAST = [ "created_at",
|
||||
"last_sign_in_at",
|
||||
"last_sign_in_ip",
|
||||
"sign_in_count",
|
||||
"updated_at",
|
||||
"current_sign_in_at" ]
|
||||
|
||||
# Determine if the changes to the model are worth broadcasting or not.
|
||||
# Reduces network noise.
|
||||
def notable_changes?
|
||||
!self.saved_changes.except(*self.class::DONT_BROADCAST).empty?
|
||||
end
|
||||
|
||||
def broadcast?
|
||||
Device.current && (destroyed? || notable_changes?)
|
||||
end
|
||||
|
||||
def maybe_broadcast
|
||||
self.broadcast! if broadcast?
|
||||
end
|
||||
|
||||
def broadcast_payload
|
||||
{
|
||||
args: {
|
||||
label: (Device.current_jwt || {})[:jti] || "UNKNOWN"
|
||||
},
|
||||
body: (destroyed? ? nil : self).as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def chan_name
|
||||
"sync.#{self.class.name}.#{self.id}"
|
||||
end
|
||||
|
||||
def broadcast!
|
||||
# Thread.new { `play ~/tada.wav` }
|
||||
Transport.send(broadcast_payload, Device.current.id, chan_name)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,4 +28,24 @@ class Device < ApplicationRecord
|
|||
def auth_token
|
||||
SessionToken.as_json(self.users.first)[:token].encoded
|
||||
end
|
||||
|
||||
# Send a realtime message to a logged in user.
|
||||
def tell(message, chan = "toast")
|
||||
log = Log.new({ device: self,
|
||||
message: message,
|
||||
created_at: Time.now,
|
||||
channels: [chan],
|
||||
meta: { type: "info" } })
|
||||
json = LogSerializer.new(log).as_json.to_json
|
||||
|
||||
Transport.send(json, self.id, "logs")
|
||||
end
|
||||
|
||||
def self.current
|
||||
Thread.current[:device]
|
||||
end
|
||||
|
||||
def self.current_jwt
|
||||
Thread.current[:jwt]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,4 +18,8 @@ class LogDispatch < ApplicationRecord
|
|||
.log_digest(device)
|
||||
.deliver_later
|
||||
end
|
||||
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,4 +9,8 @@ class SequenceDependency < ApplicationRecord
|
|||
belongs_to :sequence
|
||||
validates_presence_of :sequence
|
||||
belongs_to :dependency, polymorphic: true
|
||||
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
# Keeps track of JSON Web Tokens that have been revoked prior to their
|
||||
# expiration date.
|
||||
class TokenExpiration < ApplicationRecord
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
25
app/models/transport.rb
Normal file
25
app/models/transport.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# A wrapper around AMQP to stay DRY. Will make life easier if we ever need to
|
||||
# change protocols
|
||||
module Transport
|
||||
|
||||
AMQP_URL = ENV['CLOUDAMQP_URL'] ||
|
||||
ENV['RABBITMQ_URL'] ||
|
||||
"amqp://guest:guest@localhost:5672"
|
||||
|
||||
def self.connection
|
||||
@connection ||= Bunny
|
||||
.new(AMQP_URL, read_timeout: 10, heartbeat: 10)
|
||||
.start
|
||||
end
|
||||
|
||||
def self.topic
|
||||
@topic ||= self
|
||||
.connection
|
||||
.create_channel
|
||||
.topic("amq.topic", auto_delete: true)
|
||||
end
|
||||
|
||||
def self.send(message, id, channel)
|
||||
topic.publish(message, routing_key: "bot.device_#{id}.#{channel}")
|
||||
end
|
||||
end
|
|
@ -7,7 +7,9 @@ module Auth
|
|||
def execute
|
||||
token = SessionToken.decode!(just_the_token)
|
||||
claims = token.unencoded
|
||||
User.find_by_email_or_id(claims["sub"])
|
||||
Thread.current[:jwt] = claims.deep_symbolize_keys
|
||||
User
|
||||
.find_by_email_or_id(claims["sub"])
|
||||
rescue JWT::DecodeError, ActiveRecord::RecordNotFound, User::BadSub
|
||||
add_error :jwt, :decode_error, Auth::ReloadToken::BAD_SUB
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ Bundler.require(:default, Rails.env)
|
|||
module FarmBot
|
||||
class Application < Rails::Application
|
||||
|
||||
config.active_job.queue_adapter = :delayed_job
|
||||
config.active_job.queue_adapter = :sneakers
|
||||
config.action_dispatch.perform_deep_munge = false
|
||||
I18n.enforce_available_locales = false
|
||||
config.generators do |g|
|
||||
|
@ -34,5 +34,10 @@ module FarmBot
|
|||
Rails.application.routes.default_url_options[:port] = ENV["API_PORT"] || 3000
|
||||
# ¯\_(ツ)_/¯
|
||||
$API_URL = "//#{ Rails.application.routes.default_url_options[:host] }:#{ Rails.application.routes.default_url_options[:port] }"
|
||||
|
||||
Sneakers.configure(amqp: 'amqp://localhost',
|
||||
vhost: '/',
|
||||
exchange: 'sneakers',
|
||||
exchange_type: :direct)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -138,7 +138,6 @@ result.push(enum_type :LegalArgString, HASH[:args].map{ |x| x[:name] }.sort.uniq
|
|||
result.push(enum_type :LegalKindString, HASH[:nodes].map{ |x| x[:name] }.sort.uniq)
|
||||
result.push(enum_type :LegalSequenceKind, CeleryScriptSettingsBag::STEPS.sort)
|
||||
result.push(enum_type :DataChangeType, CeleryScriptSettingsBag::ALLOWED_CHAGES)
|
||||
result.push(enum_type :ResourceName, CeleryScriptSettingsBag::RESOURCE_NAME)
|
||||
result.push(enum_type :PointType, CeleryScriptSettingsBag::ALLOWED_POINTER_TYPE)
|
||||
|
||||
puts result.join.gsub("\n\n\n", "\n")
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
"@types/lodash": "^4.14.78",
|
||||
"@types/markdown-it": "^0.0.4",
|
||||
"@types/moxios": "^0.4.5",
|
||||
"@types/mqtt": "^2.5.0",
|
||||
"@types/node": "^8.0.46",
|
||||
"@types/react": "15.0.39",
|
||||
"@types/react-color": "^2.13.2",
|
||||
|
@ -53,7 +52,7 @@
|
|||
"enzyme": "^3.1.0",
|
||||
"enzyme-adapter-react-15": "^1.0.2",
|
||||
"extract-text-webpack-plugin": "^3.0.1",
|
||||
"farmbot": "5.0.1",
|
||||
"farmbot": "https://github.com/RickCarlino/farmbot-js.git",
|
||||
"farmbot-toastr": "^1.0.3",
|
||||
"fastclick": "^1.0.6",
|
||||
"file-loader": "^1.1.5",
|
||||
|
|
|
@ -63,10 +63,12 @@ export function editStep({ step, sequence, index, executor }: EditStepProps) {
|
|||
}
|
||||
|
||||
/** Initialize (but don't save) an indexed / tagged resource. */
|
||||
export function init(resource: TaggedResource): ReduxAction<TaggedResource> {
|
||||
resource.body.id = 0;
|
||||
resource.specialStatus = SpecialStatus.DIRTY;
|
||||
/** Technically, this happens in the reducer, but I like to be extra safe. */
|
||||
export function init(resource: TaggedResource,
|
||||
/** Set to "true" when you want an `undefined` SpecialStatus. */
|
||||
clean = false): ReduxAction<TaggedResource> {
|
||||
resource.body.id = resource.body.id || 0;
|
||||
resource.specialStatus = clean ? undefined : SpecialStatus.DIRTY;
|
||||
/** Don't touch this- very important! */
|
||||
resource.uuid = generateUuid(resource.body.id, resource.kind);
|
||||
return { type: Actions.INIT_RESOURCE, payload: resource };
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { init } from "../api/crud";
|
|||
import { versionOK } from "../devices/reducer";
|
||||
import { AuthState } from "../auth/interfaces";
|
||||
import { TaggedResource } from "../resources/tagged_resources";
|
||||
import { TempDebug } from "./temp_debug";
|
||||
|
||||
export const TITLE = "New message from bot";
|
||||
|
||||
|
@ -65,7 +66,7 @@ export const initLog = (log: Log): ReduxAction<TaggedResource> => init({
|
|||
specialStatus: undefined,
|
||||
uuid: "MUST_CHANGE",
|
||||
body: log
|
||||
});
|
||||
}, true);
|
||||
|
||||
export const bothUp = () => {
|
||||
dispatchNetworkUp("user.mqtt");
|
||||
|
@ -143,10 +144,11 @@ const attachEventListeners =
|
|||
bot.on("online", onOnline);
|
||||
bot.on("offline", onOffline);
|
||||
bot.on("sent", onSent(bot.client));
|
||||
bot.on("Log", onLogs(dispatch));
|
||||
bot.on("logs", onLogs(dispatch));
|
||||
bot.on("status", onStatus(dispatch, getState));
|
||||
bot.on("malformed", onMalformed);
|
||||
readStatus().then(changeLastClientConnected(bot), noop);
|
||||
bot.client.on("message", TempDebug(dispatch, getState));
|
||||
};
|
||||
|
||||
/** Connect to MQTT and attach all relevant event handlers. */
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { store } from "../redux/store";
|
||||
import { networkUp, networkDown } from "./actions";
|
||||
import { Edge } from "./interfaces";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
/** Debounce calls to these functions to avoid unnecessary re-paints. */
|
||||
const SLOWDOWN_TIME = 1500;
|
||||
|
||||
/* 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
|
||||
|
@ -8,11 +12,11 @@ directly importing `store`, but in this particular instance it might be
|
|||
unavoidable. */
|
||||
|
||||
export let dispatchNetworkUp =
|
||||
(edge: Edge, at = (new Date()).toJSON()) => {
|
||||
debounce((edge: Edge, at = (new Date()).toJSON()) => {
|
||||
return store.dispatch(networkUp(edge, at));
|
||||
};
|
||||
}, SLOWDOWN_TIME);
|
||||
|
||||
export let dispatchNetworkDown =
|
||||
(edge: Edge, at = (new Date()).toJSON()) => {
|
||||
debounce((edge: Edge, at = (new Date()).toJSON()) => {
|
||||
return store.dispatch(networkDown(edge, at));
|
||||
};
|
||||
}, SLOWDOWN_TIME);
|
||||
|
|
140
webpack/connectivity/temp_debug.ts
Normal file
140
webpack/connectivity/temp_debug.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { GetState } from "../redux/interfaces";
|
||||
import { maybeDetermineUuid } from "../resources/selectors";
|
||||
import { ResourceName, TaggedResource } from "../resources/tagged_resources";
|
||||
import { destroyOK } from "../resources/actions";
|
||||
import { overwrite, init } from "../api/crud";
|
||||
import { fancyDebug } from "../util";
|
||||
|
||||
interface UpdateMqttData {
|
||||
status: "UPDATE"
|
||||
kind: ResourceName;
|
||||
id: number;
|
||||
body: object;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface DeleteMqttData {
|
||||
status: "DELETE"
|
||||
kind: ResourceName;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface BadMqttData {
|
||||
status: "ERR";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface SkipMqttData {
|
||||
status: "SKIP";
|
||||
}
|
||||
|
||||
type MqttDataResult =
|
||||
| UpdateMqttData
|
||||
| DeleteMqttData
|
||||
| SkipMqttData
|
||||
| BadMqttData;
|
||||
|
||||
enum Reason {
|
||||
BAD_KIND = "missing `kind`",
|
||||
BAD_ID = "No ID or invalid ID.",
|
||||
BAD_CHAN = "Expected exactly 5 segments in channel"
|
||||
}
|
||||
|
||||
interface SyncPayload {
|
||||
args: { label: string; };
|
||||
body: object | undefined;
|
||||
}
|
||||
|
||||
function decodeBinary(payload: Buffer): SyncPayload {
|
||||
return JSON.parse((payload).toString());
|
||||
}
|
||||
function routeMqttData(chan: string, payload: Buffer): MqttDataResult {
|
||||
/** Skip irrelevant messages */
|
||||
if (!chan.includes("sync")) { return { status: "SKIP" }; }
|
||||
|
||||
/** Extract, Validate and scrub the data as it enters the frontend. */
|
||||
const parts = chan.split("/");
|
||||
if (parts.length !== 5) { return { status: "ERR", reason: Reason.BAD_CHAN }; }
|
||||
|
||||
const id = parseInt(parts.pop() || "0", 10);
|
||||
const kind = parts.pop() as ResourceName | undefined;
|
||||
const { body, args } = decodeBinary(payload);
|
||||
|
||||
if (!kind) { return { status: "ERR", reason: Reason.BAD_KIND }; }
|
||||
if (!id) { return { status: "ERR", reason: Reason.BAD_ID }; }
|
||||
|
||||
if (body) {
|
||||
return { status: "UPDATE", body, kind: kind, id, sessionId: args.label };
|
||||
} else {
|
||||
return { status: "DELETE", kind: kind, id }; // 'null' body means delete.
|
||||
}
|
||||
}
|
||||
|
||||
const asTaggedResource = (data: UpdateMqttData, uuid: string): TaggedResource => {
|
||||
return {
|
||||
// tslint:disable-next-line:no-any
|
||||
kind: (data.kind as any),
|
||||
uuid,
|
||||
specialStatus: undefined,
|
||||
// tslint:disable-next-line:no-any
|
||||
body: (data.body as any) // I trust you, API...
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate =
|
||||
(data: UpdateMqttData) => init(asTaggedResource(data, "IS SET LATER"), true);
|
||||
const handleUpdate =
|
||||
(d: UpdateMqttData, uid: string) => {
|
||||
const tr = asTaggedResource(d, uid);
|
||||
return overwrite(tr, tr.body);
|
||||
};
|
||||
|
||||
const handleErr = (d: BadMqttData) => console.log("DATA VALIDATION ERROR!", d);
|
||||
|
||||
const handleSkip = () => console.log("SKIP");
|
||||
|
||||
export const TempDebug =
|
||||
(dispatch: Function, getState: GetState) =>
|
||||
(chan: string, payload: Buffer) => {
|
||||
const data = routeMqttData(chan, payload);
|
||||
|
||||
switch (data.status) {
|
||||
case "ERR": return handleErr(data);
|
||||
case "SKIP": return handleSkip();
|
||||
case "DELETE":
|
||||
const { id, kind } = data;
|
||||
const i = getState().resources.index;
|
||||
const r = i.references[maybeDetermineUuid(i, kind, id) || "NO"];
|
||||
if (r) {
|
||||
return dispatch(destroyOK(r));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
case "UPDATE":
|
||||
whoah(dispatch, getState, data);
|
||||
}
|
||||
};
|
||||
|
||||
// Here be dragons.
|
||||
// The ultimate problem: We need to know if the incoming data update was created
|
||||
// by us or some other user. That information lets us know if we are UPDATEing
|
||||
// data or INSERTing data.
|
||||
function whoah(dispatch: Function,
|
||||
getState: GetState,
|
||||
data: UpdateMqttData,
|
||||
backoff = 200) {
|
||||
const state = getState();
|
||||
const { index } = state.resources;
|
||||
const jti: string =
|
||||
(state.auth && (state.auth.token.unencoded as any)["jti"]) || "";
|
||||
fancyDebug({ jti, ...data });
|
||||
|
||||
const uuid = maybeDetermineUuid(index, data.kind, data.id);
|
||||
if (uuid) {
|
||||
return dispatch(handleUpdate(data, uuid));
|
||||
} else {
|
||||
if (data.sessionId !== jti) { // Ignores local echo.
|
||||
dispatch(handleCreate(data));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -372,7 +372,7 @@ export enum Actions {
|
|||
// Resources
|
||||
DESTROY_RESOURCE_OK = "DESTROY_RESOURCE_OK",
|
||||
INIT_RESOURCE = "INIT_RESOURCE",
|
||||
SAVE_SPECIAL_RESOURCE = "SAVE_SPECIAL_RESOURCE",
|
||||
SAVE_OPENFARM_RESOURCE = "SAVE_OPENFARM_RESOURCE",
|
||||
SAVE_RESOURCE_OK = "SAVE_RESOURCE_OK",
|
||||
UPDATE_RESOURCE_OK = "UPDATE_RESOURCE_OK",
|
||||
EDIT_RESOURCE = "EDIT_RESOURCE",
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import { DataChangeType, Dictionary } from "farmbot/dist";
|
||||
import { getDevice } from "./device";
|
||||
import { box } from "boxed_value";
|
||||
import * as _ from "lodash";
|
||||
import { ResourceName } from "./resources/tagged_resources";
|
||||
|
||||
export let METHOD_MAP: Dictionary<DataChangeType> = {
|
||||
"post": "add",
|
||||
"put": "update",
|
||||
"patch": "update",
|
||||
"delete": "remove"
|
||||
};
|
||||
export let METHODS = ["post", "put", "patch", "delete"];
|
||||
|
||||
export let RESOURCES: ResourceName[] = [
|
||||
"Point",
|
||||
"Regimen",
|
||||
"Peripheral",
|
||||
"Log",
|
||||
"Sequence",
|
||||
"FarmEvent",
|
||||
"Point",
|
||||
"Device"
|
||||
];
|
||||
|
||||
// PROBLEM: The bot doesn't know if the user has changed any of the data.
|
||||
// GOOD SOLUTION: Create a push notification system on the API.
|
||||
// FAST SOLUTION: Ping the bot every time we push "save" or "update".
|
||||
// Our hope is to eventually move this logic into the API.
|
||||
export function notifyBotOfChanges(url: string | undefined, action: DataChangeType) {
|
||||
if (url) {
|
||||
url.split("/").filter((chunk: ResourceName) => {
|
||||
return RESOURCES.includes(chunk);
|
||||
}).map(async function (resource: ResourceName) {
|
||||
getDevice().dataUpdate(action, { [resource]: inferUpdateId(url) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** More nasty hacks until we have time to implement proper API push state
|
||||
* notifications. */
|
||||
function inferUpdateId(url: string) {
|
||||
try {
|
||||
let ids = url
|
||||
.split("/")
|
||||
.filter(x => !x.includes(",")) // Don't allow batch endpoints to participate.
|
||||
.map(x => parseInt(x, 10))
|
||||
.filter(x => !_.isNaN(x));
|
||||
let id: number | undefined = ids[0];
|
||||
let isNum = _.isNumber(id);
|
||||
let onlyOne = ids.length === 1;
|
||||
return (isNum && onlyOne) ? ("" + id) : "*";
|
||||
} catch (error) { // Don't crash - just keep moving along. This is a temp patch.
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
|
||||
/** The input of an axios error interceptor is an "any" type.
|
||||
* Sometimes it will be a real Axios error, other times it will not be.
|
||||
*/
|
||||
export interface SafeError {
|
||||
response: {
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Prevents hard-to-find NPEs and type errors inside of interceptors. */
|
||||
export function isSafeError(x: SafeError | any): x is SafeError {
|
||||
return !!(
|
||||
(box(x).kind === "object") &&
|
||||
(box(x.response).kind === "object") &&
|
||||
(box(x.response.status).kind === "number"));
|
||||
}
|
|
@ -1,25 +1,34 @@
|
|||
import { t } from "i18next";
|
||||
import { error } from "farmbot-toastr";
|
||||
import {
|
||||
METHODS,
|
||||
notifyBotOfChanges,
|
||||
METHOD_MAP,
|
||||
SafeError,
|
||||
isSafeError
|
||||
} from "./interceptor_support";
|
||||
import { API } from "./api/index";
|
||||
import { AuthState } from "./auth/interfaces";
|
||||
import * as _ from "lodash";
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { Content } from "./constants";
|
||||
import { dispatchNetworkUp, dispatchNetworkDown } from "./connectivity/index";
|
||||
import { box } from "boxed_value";
|
||||
import { UnsafeError } from "./interfaces";
|
||||
import { store } from "./redux/store";
|
||||
|
||||
/** The input of an axios error interceptor is an "any" type.
|
||||
* Sometimes it will be a real Axios error, other times it will not be.
|
||||
*/
|
||||
export interface SafeError {
|
||||
response: {
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Prevents hard-to-find NPEs and type errors inside of interceptors. */
|
||||
export function isSafeError(x: SafeError | UnsafeError): x is SafeError {
|
||||
return !!(
|
||||
(box(x).kind === "object") &&
|
||||
(box(x.response).kind === "object") &&
|
||||
(box(x.response.status).kind === "number"));
|
||||
}
|
||||
|
||||
export function responseFulfilled(input: AxiosResponse): AxiosResponse {
|
||||
const method = input.config.method;
|
||||
dispatchNetworkUp("user.api");
|
||||
if (method && METHODS.includes(method)) {
|
||||
notifyBotOfChanges(input.config.url, METHOD_MAP[method]);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
|
@ -64,9 +73,9 @@ export function requestFulfilled(auth: AuthState) {
|
|||
const isAPIRequest = req.includes(API.current.baseUrl);
|
||||
if (isAPIRequest) {
|
||||
config.headers = config.headers || {};
|
||||
const headers = (config.headers as
|
||||
{ Authorization: string | undefined });
|
||||
headers.Authorization = auth.token.encoded || "CANT_FIND_TOKEN";
|
||||
const headers: { Authorization: string | undefined } = (config.headers);
|
||||
const authn = store.getState().auth;
|
||||
headers.Authorization = authn ? authn.token.encoded : "CANT_FIND_TOKEN";
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
|
|
@ -87,7 +87,7 @@ const afterEach = (state: RestResources, a: ReduxAction<object>) => {
|
|||
/** Responsible for all RESTful resources. */
|
||||
export let resourceReducer = generateReducer
|
||||
<RestResources>(initialState, afterEach)
|
||||
.add<ResourceReadyPayl>(Actions.SAVE_SPECIAL_RESOURCE, (s, { payload }) => {
|
||||
.add<ResourceReadyPayl>(Actions.SAVE_OPENFARM_RESOURCE, (s, { payload }) => {
|
||||
const data = arrayWrap(payload);
|
||||
const kind = payload.name;
|
||||
data.map((body: ResourceReadyPayl) => {
|
||||
|
@ -192,17 +192,10 @@ export let resourceReducer = generateReducer
|
|||
dontTouchThis(original);
|
||||
return s;
|
||||
})
|
||||
.add<TaggedResource>(Actions.INIT_RESOURCE, (s, { payload }) => {
|
||||
.add<TaggedResource>(Actions.INIT_RESOURCE, (s: RestResources, { payload }) => {
|
||||
const tr = payload;
|
||||
const uuid = tr.uuid;
|
||||
reindexResource(s.index, tr);
|
||||
if (tr.kind === "Log") {
|
||||
// Since logs don't come from the API all the time, they are the only
|
||||
// resource (right now) that can have an id of `undefined` and not dirty.
|
||||
findByUuid(s.index, uuid).specialStatus = undefined;
|
||||
} else {
|
||||
findByUuid(s.index, uuid).specialStatus = SpecialStatus.DIRTY;
|
||||
}
|
||||
s.index.references[tr.uuid] = tr;
|
||||
sanityCheck(tr);
|
||||
dontTouchThis(tr);
|
||||
return s;
|
||||
|
@ -288,7 +281,9 @@ export function joinKindAndId(kind: ResourceName, id: number | undefined) {
|
|||
return `${kind}.${id || 0}`;
|
||||
}
|
||||
|
||||
const filterOutUuid = (tr: TaggedResource) => (uuid: string) => uuid !== tr.uuid;
|
||||
const filterOutUuid =
|
||||
(tr: TaggedResource) => (uuid: string) => uuid !== tr.uuid;
|
||||
|
||||
function removeFromIndex(index: ResourceIndex, tr: TaggedResource) {
|
||||
const { kind } = tr;
|
||||
const id = tr.body.id;
|
||||
|
|
|
@ -31,10 +31,22 @@ import {
|
|||
import { CowardlyDictionary, betterCompact, sortResourcesById } from "../util";
|
||||
type StringMap = CowardlyDictionary<string>;
|
||||
|
||||
export let findId = (index: ResourceIndex, kind: ResourceName, id: number) => {
|
||||
/** Similar to findId(), but does not throw exceptions. Do NOT use this method
|
||||
* unless there is actually a reason for the resource to not have a UUID.
|
||||
* `findId()` is more appropriate 99% of the time because it can spot
|
||||
* referential integrity issues. */
|
||||
export let maybeDetermineUuid =
|
||||
(index: ResourceIndex, kind: ResourceName, id: number) => {
|
||||
const kni = joinKindAndId(kind, id);
|
||||
const uuid = index.byKindAndId[kni];
|
||||
if (uuid) {
|
||||
assertUuid(kind, uuid);
|
||||
return uuid;
|
||||
}
|
||||
};
|
||||
|
||||
const uuid = index.byKindAndId[joinKindAndId(kind, id)];
|
||||
assertUuid(kind, uuid);
|
||||
export let findId = (index: ResourceIndex, kind: ResourceName, id: number) => {
|
||||
const uuid = maybeDetermineUuid(index, kind, id);
|
||||
if (uuid) {
|
||||
return uuid;
|
||||
} else {
|
||||
|
|
|
@ -5,7 +5,8 @@ import { TaggedResource } from "./tagged_resources";
|
|||
/** HISTORICAL NOTES:
|
||||
* This file is the result of some very subtle bugs relating to dynamic
|
||||
* children in React components on the sequence editor page. Simply put, the
|
||||
* sequence editor needs a way to uniquely identify each step when rendering.
|
||||
* sequence editor needs a way to uniquely identify each sequence step when
|
||||
* rendering a sequence.
|
||||
*
|
||||
* PROBLEM:
|
||||
* - React needs a unique `key` prop when iterating over UI list elements.
|
||||
|
|
|
@ -204,7 +204,7 @@ export class RootComponent extends React.Component<RootComponentProps, {}> {
|
|||
},
|
||||
},
|
||||
{
|
||||
path: "FarmEvent",
|
||||
path: "farm_events",
|
||||
getComponent(_discard: void, cb: Function) {
|
||||
import("./farm_designer/farm_events/farm_events")
|
||||
.then(module => cb(undefined, module.FarmEvents))
|
||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -109,12 +109,6 @@
|
|||
dependencies:
|
||||
axios "^0.16.1"
|
||||
|
||||
"@types/mqtt@^2.5.0":
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/mqtt/-/mqtt-2.5.0.tgz#bc54c2d53f509282168da4a9af865de95bee5101"
|
||||
dependencies:
|
||||
mqtt "*"
|
||||
|
||||
"@types/node@^6.0.46":
|
||||
version "6.0.90"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.90.tgz#0ed74833fa1b73dcdb9409dcb1c97ec0a8b13b02"
|
||||
|
@ -2009,6 +2003,13 @@ farmbot@5.0.1:
|
|||
mqtt "^2.13.1"
|
||||
typescript "^2.4.2"
|
||||
|
||||
"farmbot@https://github.com/RickCarlino/farmbot-js.git":
|
||||
version "5.0.1"
|
||||
resolved "https://github.com/RickCarlino/farmbot-js.git#d27f640d15cada92fa5d2cfa28957c0fe2530a3d"
|
||||
dependencies:
|
||||
mqtt "^2.13.1"
|
||||
typescript "^2.4.2"
|
||||
|
||||
fast-deep-equal@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-0.1.0.tgz#5c6f4599aba6b333ee3342e2ed978672f1001f8d"
|
||||
|
@ -3799,7 +3800,7 @@ mqtt-packet@^5.4.0:
|
|||
process-nextick-args "^1.0.7"
|
||||
safe-buffer "^5.1.0"
|
||||
|
||||
mqtt@*, mqtt@^2.13.1:
|
||||
mqtt@^2.13.1:
|
||||
version "2.13.1"
|
||||
resolved "https://registry.yarnpkg.com/mqtt/-/mqtt-2.13.1.tgz#d83c7c5d9dc37864a363453f61fb5e1523c0144a"
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue