Merge pull request #1550 from FarmBot/staging

v8.1.3 - Iridescent Iris
pull/1552/head
Rick Carlino 2019-10-30 14:55:28 -05:00 committed by GitHub
commit 011e900aee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
128 changed files with 1039 additions and 506 deletions

View File

@ -1,6 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
ruby "~> 2.6.5" ruby "~> 2.6.5"
gem "rails"
gem "active_model_serializers" gem "active_model_serializers"
gem "bunny" gem "bunny"
gem "delayed_job_active_record" # TODO: Get off of SQL backed jobs. Use Redis gem "delayed_job_active_record" # TODO: Get off of SQL backed jobs. Use Redis
@ -16,7 +17,6 @@ gem "rabbitmq_http_api_client"
gem "rack-attack" gem "rack-attack"
gem "rack-cors" gem "rack-cors"
gem "rails_12factor" gem "rails_12factor"
gem "rails", "5.2.3" # TODO: Upgrade to Rails 6
gem "redis", "~> 4.0" gem "redis", "~> 4.0"
gem "request_store" gem "request_store"
gem "rollbar" gem "rollbar"
@ -35,7 +35,7 @@ group :development, :test do
gem "hashdiff" gem "hashdiff"
gem "pry-rails" gem "pry-rails"
gem "pry" gem "pry"
gem "rspec-rails" gem "rspec-rails", "4.0.0.beta3"
gem "rspec" gem "rspec"
gem "simplecov" gem "simplecov"
gem "smarf_doc", git: "https://github.com/RickCarlino/smarf_doc.git" gem "smarf_doc", git: "https://github.com/RickCarlino/smarf_doc.git"

View File

@ -7,56 +7,69 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.2.3) actioncable (6.0.0)
actionpack (= 5.2.3) actionpack (= 6.0.0)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailer (5.2.3) actionmailbox (6.0.0)
actionpack (= 5.2.3) actionpack (= 6.0.0)
actionview (= 5.2.3) activejob (= 6.0.0)
activejob (= 5.2.3) activerecord (= 6.0.0)
activestorage (= 6.0.0)
activesupport (= 6.0.0)
mail (>= 2.7.1)
actionmailer (6.0.0)
actionpack (= 6.0.0)
actionview (= 6.0.0)
activejob (= 6.0.0)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.2.3) actionpack (6.0.0)
actionview (= 5.2.3) actionview (= 6.0.0)
activesupport (= 5.2.3) activesupport (= 6.0.0)
rack (~> 2.0) rack (~> 2.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (5.2.3) actiontext (6.0.0)
activesupport (= 5.2.3) actionpack (= 6.0.0)
activerecord (= 6.0.0)
activestorage (= 6.0.0)
activesupport (= 6.0.0)
nokogiri (>= 1.8.5)
actionview (6.0.0)
activesupport (= 6.0.0)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_serializers (0.10.10) active_model_serializers (0.10.10)
actionpack (>= 4.1, < 6.1) actionpack (>= 4.1, < 6.1)
activemodel (>= 4.1, < 6.1) activemodel (>= 4.1, < 6.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (5.2.3) activejob (6.0.0)
activesupport (= 5.2.3) activesupport (= 6.0.0)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.2.3) activemodel (6.0.0)
activesupport (= 5.2.3) activesupport (= 6.0.0)
activerecord (5.2.3) activerecord (6.0.0)
activemodel (= 5.2.3) activemodel (= 6.0.0)
activesupport (= 5.2.3) activesupport (= 6.0.0)
arel (>= 9.0) activestorage (6.0.0)
activestorage (5.2.3) actionpack (= 6.0.0)
actionpack (= 5.2.3) activejob (= 6.0.0)
activerecord (= 5.2.3) activerecord (= 6.0.0)
marcel (~> 0.3.1) marcel (~> 0.3.1)
activesupport (5.2.3) activesupport (6.0.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
minitest (~> 5.1) minitest (~> 5.1)
tzinfo (~> 1.1) tzinfo (~> 1.1)
zeitwerk (~> 2.1, >= 2.1.8)
addressable (2.7.0) addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
amq-protocol (2.3.0) amq-protocol (2.3.0)
arel (9.0.0)
bcrypt (3.1.13) bcrypt (3.1.13)
builder (3.2.3) builder (3.2.3)
bunny (2.14.3) bunny (2.14.3)
@ -106,7 +119,7 @@ GEM
railties (>= 3.2, < 6.1) railties (>= 3.2, < 6.1)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
google-api-client (0.33.1) google-api-client (0.33.2)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9) googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0) httpclient (>= 2.8.1, < 3.0)
@ -114,9 +127,9 @@ GEM
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.0) retriable (>= 2.0, < 4.0)
signet (~> 0.12) signet (~> 0.12)
google-cloud-core (1.3.2) google-cloud-core (1.4.0)
google-cloud-env (~> 1.0) google-cloud-env (~> 1.0)
google-cloud-env (1.2.1) google-cloud-env (1.3.0)
faraday (~> 0.11) faraday (~> 0.11)
google-cloud-storage (1.21.1) google-cloud-storage (1.21.1)
addressable (~> 2.5) addressable (~> 2.5)
@ -183,18 +196,20 @@ GEM
rack-cors (1.0.3) rack-cors (1.0.3)
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rails (5.2.3) rails (6.0.0)
actioncable (= 5.2.3) actioncable (= 6.0.0)
actionmailer (= 5.2.3) actionmailbox (= 6.0.0)
actionpack (= 5.2.3) actionmailer (= 6.0.0)
actionview (= 5.2.3) actionpack (= 6.0.0)
activejob (= 5.2.3) actiontext (= 6.0.0)
activemodel (= 5.2.3) actionview (= 6.0.0)
activerecord (= 5.2.3) activejob (= 6.0.0)
activestorage (= 5.2.3) activemodel (= 6.0.0)
activesupport (= 5.2.3) activerecord (= 6.0.0)
activestorage (= 6.0.0)
activesupport (= 6.0.0)
bundler (>= 1.3.0) bundler (>= 1.3.0)
railties (= 5.2.3) railties (= 6.0.0)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -206,12 +221,12 @@ GEM
rails_stdout_logging rails_stdout_logging
rails_serve_static_assets (0.0.5) rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5) rails_stdout_logging (0.0.5)
railties (5.2.3) railties (6.0.0)
actionpack (= 5.2.3) actionpack (= 6.0.0)
activesupport (= 5.2.3) activesupport (= 6.0.0)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (>= 0.20.3, < 2.0)
rake (13.0.0) rake (13.0.0)
redis (4.1.3) redis (4.1.3)
representable (3.0.4) representable (3.0.4)
@ -237,14 +252,14 @@ GEM
rspec-mocks (3.9.0) rspec-mocks (3.9.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0) rspec-support (~> 3.9.0)
rspec-rails (3.9.0) rspec-rails (4.0.0.beta3)
actionpack (>= 3.0) actionpack (>= 4.2)
activesupport (>= 3.0) activesupport (>= 4.2)
railties (>= 3.0) railties (>= 4.2)
rspec-core (~> 3.9.0) rspec-core (~> 3.8)
rspec-expectations (~> 3.9.0) rspec-expectations (~> 3.8)
rspec-mocks (~> 3.9.0) rspec-mocks (~> 3.8)
rspec-support (~> 3.9.0) rspec-support (~> 3.8)
rspec-support (3.9.0) rspec-support (3.9.0)
scenic (1.5.1) scenic (1.5.1)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
@ -281,6 +296,7 @@ GEM
websocket-driver (0.7.1) websocket-driver (0.7.1)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4) websocket-extensions (0.1.4)
zeitwerk (2.2.0)
zero_downtime_migrations (0.0.7) zero_downtime_migrations (0.0.7)
activerecord activerecord
@ -311,13 +327,13 @@ DEPENDENCIES
rabbitmq_http_api_client rabbitmq_http_api_client
rack-attack rack-attack
rack-cors rack-cors
rails (= 5.2.3) rails
rails_12factor rails_12factor
redis (~> 4.0) redis (~> 4.0)
request_store request_store
rollbar rollbar
rspec rspec
rspec-rails rspec-rails (= 4.0.0.beta3)
scenic scenic
secure_headers secure_headers
simplecov simplecov

View File

@ -136,7 +136,7 @@ module Api
strategy = Auth::DetermineAuthStrategy.run!(context) strategy = Auth::DetermineAuthStrategy.run!(context)
case strategy case strategy
when :jwt when :jwt
sign_in(Auth::FromJWT.run!(context).require_consent!) sign_in(Auth::FromJwt.run!(context).require_consent!)
when :already_connected when :already_connected
# Probably provided a cookie. # Probably provided a cookie.
# 9 times out of 10, it's a unit test. # 9 times out of 10, it's a unit test.

View File

@ -44,7 +44,7 @@ module Api
end end
def update_fields def update_fields
user.update_attributes!(confirmed_at: Time.now) user.update!(confirmed_at: Time.now)
end end
def seed_user def seed_user

View File

@ -1,4 +1,4 @@
require_relative "../../lib/hstore_filter" # require_relative "../../lib/mutations/hstore_filter"
module Api module Api
class PointsController < Api::AbstractController class PointsController < Api::AbstractController

View File

@ -273,7 +273,7 @@ module Api
end end
def current_device def current_device
@current_device ||= Auth::FromJWT.run!(jwt: password_param).device @current_device ||= Auth::FromJwt.run!(jwt: password_param).device
rescue Mutations::ValidationException => e rescue Mutations::ValidationException => e
raise JWT::VerificationError, "RMQ Provided bad token" raise JWT::VerificationError, "RMQ Provided bad token"
end end

View File

@ -11,16 +11,18 @@ class AmqpLogParser < Mutations::Command
# I keep a Ruby copy of the JSON here for reference. # I keep a Ruby copy of the JSON here for reference.
# This is what a log will look like after JSON.parse() # This is what a log will look like after JSON.parse()
EXAMPLE_JSON = { EXAMPLE_JSON = {
"meta" => {
"x" => 0,
"y" => 0,
"z" => 0,
"type" => "info",
},
"major_version" => 6, # <= up-to-date bots do this
"message" => "HQ FarmBot TEST 123 Pin 13 is 0",
"created_at" => 1512585641,
"channels" => [], "channels" => [],
"created_at" => 1572015955,
"major_version" => 8,
"message" => "Syncing",
"meta" => {},
"minor_version" => 1,
"patch_version" => 1,
"type" => "info",
"verbosity" => 3,
"x" => 0.0,
"y" => 0.0,
"z" => 0.0,
} }
required do required do
@ -102,8 +104,19 @@ class AmqpLogParser < Mutations::Command
end end
def find_problems! def find_problems!
@output.problems.push(NOT_HASH) and return if not_hash? if not_hash?
@output.problems.push(TOO_OLD) and return if major_version < 6 @output.problems.push(NOT_HASH)
@output.problems.push(DISCARD) and return if discard? return
end
if (major_version || 0) < 7
@output.problems.push(TOO_OLD)
return
end
if discard?
@output.problems.push(DISCARD)
return
end
end end
end end

View File

@ -9,8 +9,8 @@
# * You need to create "traces" of where you are in a sequence (using numbers) # * You need to create "traces" of where you are in a sequence (using numbers)
# MORE INFO: https://github.com/FarmBot-Labs/Celery-Slicer # MORE INFO: https://github.com/FarmBot-Labs/Celery-Slicer
module CeleryScript module CeleryScript
# Supporting class for CSHeap (below this class) # Supporting class for CsHeap (below this class)
# PROBLEM: CSHeap uses numbers to address sibling/parent nodes. # PROBLEM: CsHeap uses numbers to address sibling/parent nodes.
# PROBLEM: Numbers are very easy to mix up. Is it an array index? A SQL # PROBLEM: Numbers are very easy to mix up. Is it an array index? A SQL
# primary key? A primitive value? It's not always easy to say. # primary key? A primitive value? It's not always easy to say.
# SOLUTION: Create a `HeapAddress` value type to remove ambiguity. # SOLUTION: Create a `HeapAddress` value type to remove ambiguity.
@ -60,21 +60,22 @@ module CeleryScript
end end
end end
class CSHeap class CsHeap
class BadAddress < Exception; end; class BadAddress < Exception; end
BAD_ADDR = "Bad node address: " BAD_ADDR = "Bad node address: "
# Nodes that point to other nodes rather than primitive data types (eg: # Nodes that point to other nodes rather than primitive data types (eg:
# `locals` and friends) will be prepended with a LINK. # `locals` and friends) will be prepended with a LINK.
LINK = "__" LINK = "__"
# Points to the originator (parent) of an `arg` or `body` node. # Points to the originator (parent) of an `arg` or `body` node.
PARENT = (LINK + "parent").to_sym PARENT = (LINK + "parent").to_sym
# Points to the first element in the `body`` # Points to the first element in the `body``
BODY = (LINK + "body").to_sym BODY = (LINK + "body").to_sym
# Points to the next node in the body chain. Pointing to NOTHING indicates # Points to the next node in the body chain. Pointing to NOTHING indicates
# the end of the body linked list. # the end of the body linked list.
NEXT = (LINK + "next").to_sym NEXT = (LINK + "next").to_sym
# Unique key name. See `celery_script_settings_bag.rb` # Unique key name. See `celery_script_settings_bag.rb`
KIND = :__KIND__ KIND = :__KIND__
COMMENT = :__COMMENT__ COMMENT = :__COMMENT__
# Keys that primary nodes must have # Keys that primary nodes must have
@ -82,17 +83,16 @@ module CeleryScript
# Index 0 of the heap represents a null pointer of sorts. # Index 0 of the heap represents a null pointer of sorts.
# If a field points to this address, it is considered empty. # If a field points to this address, it is considered empty.
NULL = HeapAddress[0] NULL = HeapAddress[0]
# What you will find at index 0 of the heap: # What you will find at index 0 of the heap:
NOTHING = { NOTHING = {
KIND => "nothing", KIND => "nothing",
PARENT => NULL, PARENT => NULL,
BODY => NULL, BODY => NULL,
NEXT => NULL NEXT => NULL,
} }
# A dictionary of nodes in the CeleryScript tree, as stored in the heap. # A dictionary of nodes in the CeleryScript tree, as stored in the heap.
# Nodes will have: # Nodes will have:
# * A `KIND` field - What kind of node is it? # * A `KIND` field - What kind of node is it?
@ -113,7 +113,7 @@ module CeleryScript
# Set "here" to "null". Prepopulates "here" with an empty entry. # Set "here" to "null". Prepopulates "here" with an empty entry.
def initialize def initialize
@here = NULL @here = NULL
@entries = { @here => NOTHING } @entries = { @here => NOTHING }
end end

View File

@ -1,4 +1,4 @@
require_relative "./csheap" require_relative "./cs_heap"
# Service object that: # Service object that:
# 1. Pulls out all PrimaryNodes and EdgeNodes for a sequence node (AST Flat IR form) # 1. Pulls out all PrimaryNodes and EdgeNodes for a sequence node (AST Flat IR form)
@ -8,7 +8,7 @@ require_relative "./csheap"
# DEFAULT. # DEFAULT.
module CeleryScript module CeleryScript
class FetchCelery < Mutations::Command class FetchCelery < Mutations::Command
private # = = = = = = = private # = = = = = = =
# This class is too CPU intensive to make multiple SQL requests. # This class is too CPU intensive to make multiple SQL requests.
# To speed up querying, we create an in-memory index for frequently # To speed up querying, we create an in-memory index for frequently
# looked up attributes such as :id, :kind, :parent_id, :primary_node_id # looked up attributes such as :id, :kind, :parent_id, :primary_node_id
@ -60,7 +60,7 @@ module CeleryScript
# that node's children (or an empty array, since body is always optional). # that node's children (or an empty array, since body is always optional).
def get_body_elements(origin) def get_body_elements(origin)
next_node = find_by_id_in_memory(origin.body_id) next_node = find_by_id_in_memory(origin.body_id)
results = [] results = []
until next_node.kind == "nothing" until next_node.kind == "nothing"
results.push(next_node) results.push(next_node)
next_node = find_by_id_in_memory(next_node[:next_id]) next_node = find_by_id_in_memory(next_node[:next_id])
@ -71,7 +71,7 @@ module CeleryScript
# Top level function call for converting a single EdgeNode into a JSON # Top level function call for converting a single EdgeNode into a JSON
# document. Returns Ruby hash that conforms to CeleryScript semantics. # document. Returns Ruby hash that conforms to CeleryScript semantics.
def recurse_into_node(node) def recurse_into_node(node)
out = { kind: node.kind, args: recurse_into_args(node) } out = { kind: node.kind, args: recurse_into_args(node) }
body = get_body_elements(node) body = get_body_elements(node)
if body.empty? if body.empty?
# Legacy sequences *must* have body on sequence. Others are fine. # Legacy sequences *must* have body on sequence. Others are fine.
@ -87,16 +87,17 @@ module CeleryScript
# Eg: color, id, etc. # Eg: color, id, etc.
def misc_fields def misc_fields
return { return {
id: sequence.id, id: sequence.id,
created_at: sequence.created_at, created_at: sequence.created_at,
updated_at: sequence.updated_at, updated_at: sequence.updated_at,
args: Sequence::DEFAULT_ARGS, args: Sequence::DEFAULT_ARGS,
color: sequence.color, color: sequence.color,
name: sequence.name name: sequence.name,
} }
end end
public # = = = = = = = public # = = = = = = =
NO_SEQUENCE = "You must have a root node `sequence` at a minimum." NO_SEQUENCE = "You must have a root node `sequence` at a minimum."
required do required do

View File

@ -1,7 +1,7 @@
require_relative "./csheap" require_relative "./cs_heap"
# ABOUT THIS CLASS: # ABOUT THIS CLASS:
# CSHeap creates an in memory representation of a Flat IR tree using array # CsHeap creates an in memory representation of a Flat IR tree using array
# indexes (HeapAddress instances, really). This class takes a flat IR tree # indexes (HeapAddress instances, really). This class takes a flat IR tree
# from memory and converts `HeapAddress`es to SQL primary/foreign keys. # from memory and converts `HeapAddress`es to SQL primary/foreign keys.
module CeleryScript module CeleryScript
@ -10,14 +10,14 @@ module CeleryScript
# The following constants are abbreviations of the full name, since the # The following constants are abbreviations of the full name, since the
# full name is quite long and they are referenced frequently in the code. # full name is quite long and they are referenced frequently in the code.
# Just remember that "B" is "BODY", "K" is "KIND", etc... # Just remember that "B" is "BODY", "K" is "KIND", etc...
B = CeleryScript::CSHeap::BODY B = CeleryScript::CsHeap::BODY
C = CeleryScript::CSHeap::COMMENT C = CeleryScript::CsHeap::COMMENT
K = CeleryScript::CSHeap::KIND K = CeleryScript::CsHeap::KIND
L = CeleryScript::CSHeap::LINK L = CeleryScript::CsHeap::LINK
N = CeleryScript::CSHeap::NEXT N = CeleryScript::CsHeap::NEXT
P = CeleryScript::CSHeap::PARENT P = CeleryScript::CsHeap::PARENT
NULL = CeleryScript::CSHeap::NULL NULL = CeleryScript::CsHeap::NULL
I = :instance I = :instance
required do required do
model :sequence, class: Sequence model :sequence, class: Sequence
@ -28,7 +28,7 @@ module CeleryScript
def validate def validate
# IF YOU REMOVE THIS BAD STUFF WILL HAPPEN: # IF YOU REMOVE THIS BAD STUFF WILL HAPPEN:
# version is never user definable! # version is never user definable!
sequence_hash[:args] = \ sequence_hash[:args] =
Sequence::DEFAULT_ARGS.merge(sequence_hash[:args] || {}) Sequence::DEFAULT_ARGS.merge(sequence_hash[:args] || {})
# See comment above ^ TODO: Investigate removal now that EdgeNodes exist. # See comment above ^ TODO: Investigate removal now that EdgeNodes exist.
end end
@ -37,67 +37,67 @@ module CeleryScript
Sequence.transaction do Sequence.transaction do
flat_ir flat_ir
.each do |node| .each do |node|
# Step 1- instantiate records. # Step 1- instantiate records.
node[I] = PrimaryNode.create!(kind: node[K], node[I] = PrimaryNode.create!(kind: node[K],
sequence: sequence, sequence: sequence,
comment: node[C] || nil) comment: node[C] || nil)
end end
.each_with_index do |node, index| .each_with_index do |node, index|
# Step 2- Assign SQL ids (not to be confused with array index IDs or # Step 2- Assign SQL ids (not to be confused with array index IDs or
# instances of HeapAddress), also sets parent_arg_name # instances of HeapAddress), also sets parent_arg_name
model = node[I] model = node[I]
model.parent_arg_name = parent_arg_name_for(node, index) model.parent_arg_name = parent_arg_name_for(node, index)
model.body_id = fetch_sql_id_for(B, node) model.body_id = fetch_sql_id_for(B, node)
model.parent_id = fetch_sql_id_for(P, node) model.parent_id = fetch_sql_id_for(P, node)
model.next_id = fetch_sql_id_for(N, node) model.next_id = fetch_sql_id_for(N, node)
node node
end end
.map do |node| .map do |node|
# Step 3- Set edge nodes # Step 3- Set edge nodes
pairs = node pairs = node
.to_a .to_a
.select do |x| .select do |x|
key = x.first.to_s key = x.first.to_s
(x.first != I) && !key.starts_with?(L) (x.first != I) && !key.starts_with?(L)
end
.map do |(key, value)|
EdgeNode.create!(kind: key,
value: value,
sequence_id: sequence.id,
primary_node_id: node[:instance].id)
end
node[:instance]
end end
.tap { |x| sequence.update_attributes(migrated_nodes: true) unless sequence.migrated_nodes } .map do |(key, value)|
EdgeNode.create!(kind: key,
value: value,
sequence_id: sequence.id,
primary_node_id: node[:instance].id)
end
node[:instance]
end
.tap { |x| sequence.update(migrated_nodes: true) unless sequence.migrated_nodes }
.map { |x| .map { |x|
x.save! if x.changed? x.save! if x.changed?
x x
} }
end end
end end
private private
# Index every primary node in memory by its `HeapAddress`. # Index every primary node in memory by its `HeapAddress`.
# We need this info in order to fill out the `parent_arg_name` of a node. # We need this info in order to fill out the `parent_arg_name` of a node.
def every_primary_link def every_primary_link
@every_primary_link ||= flat_ir @every_primary_link ||= flat_ir
.map do |x| .map do |x|
x x
.except(B,C,I,K,L,N,P) .except(B, C, I, K, L, N, P)
.invert .invert
.to_a .to_a
.select{|(k,v)| k.is_a?(HeapAddress)} .select { |(k, v)| k.is_a?(HeapAddress) }
end end
.map(&:to_h) .map(&:to_h)
.reduce({}, :merge) .reduce({}, :merge)
end end
def parent_arg_name_for(node, index) def parent_arg_name_for(node, index)
resides_in_args = (node[N] == NULL) && (node[P] != NULL) resides_in_args = (node[N] == NULL) && (node[P] != NULL)
link_symbol = every_primary_link[HeapAddress[index]] link_symbol = every_primary_link[HeapAddress[index]]
needs_p_arg_name = (resides_in_args && link_symbol) needs_p_arg_name = (resides_in_args && link_symbol)
parent_arg_name = (needs_p_arg_name ? link_symbol.to_s.gsub(L, "") : nil) parent_arg_name = (needs_p_arg_name ? link_symbol.to_s.gsub(L, "") : nil)
return parent_arg_name return parent_arg_name
end end
@ -107,7 +107,7 @@ private
end end
def sequence_hash def sequence_hash
@sequence_hash ||= \ @sequence_hash ||=
HashWithIndifferentAccess.new(kind: "sequence", args: args, body: body) HashWithIndifferentAccess.new(kind: "sequence", args: args, body: body)
end end

View File

@ -2,18 +2,18 @@ module CeleryScript
# THIS IS A MORE MINIMAL VERSION OF CeleryScript::TreeClimber. # THIS IS A MORE MINIMAL VERSION OF CeleryScript::TreeClimber.
# It is a NON-VALIDATING tree climber. # It is a NON-VALIDATING tree climber.
# Don't use this on unverified data structures. # Don't use this on unverified data structures.
class JSONClimber class JsonClimber
HASH_ONLY = "Expected a Hash." HASH_ONLY = "Expected a Hash."
NOT_NODE = "Expected hash with at least a `kind` and `args` prop." NOT_NODE = "Expected hash with at least a `kind` and `args` prop."
def self.climb(thing, &callable) def self.climb(thing, &callable)
raise HASH_ONLY unless thing.is_a?(Hash) raise HASH_ONLY unless thing.is_a?(Hash)
raise NOT_NODE unless is_node?(thing) raise NOT_NODE unless is_node?(thing)
go(thing, callable) go(thing, callable)
thing thing
end end
private private
def self.is_node?(maybe) def self.is_node?(maybe)
maybe.is_a?(Hash) && maybe.is_a?(Hash) &&

View File

@ -1,4 +1,4 @@
require_relative "./csheap.rb" require_relative "./cs_heap.rb"
# ORIGINAL IMPLEMENTATION HERE: https://github.com/FarmBot-Labs/Celery-Slicer # ORIGINAL IMPLEMENTATION HERE: https://github.com/FarmBot-Labs/Celery-Slicer
# Take a nested ("canonical") representation of a CeleryScript sequence and # Take a nested ("canonical") representation of a CeleryScript sequence and
# transforms it to a flat/homogenous intermediate representation which is better # transforms it to a flat/homogenous intermediate representation which is better
@ -11,12 +11,12 @@ module CeleryScript
raise "Not a hash" unless node.is_a?(Hash) raise "Not a hash" unless node.is_a?(Hash)
@nesting_level = 0 @nesting_level = 0
@root_node = node @root_node = node
heap = CSHeap.new() heap = CsHeap.new()
allocate(heap, node, CSHeap::NULL) allocate(heap, node, CsHeap::NULL)
@heap_values = heap.values @heap_values = heap.values
@heap_values.map do |x| @heap_values.map do |x|
x[CSHeap::BODY] ||= CSHeap::NULL x[CsHeap::BODY] ||= CsHeap::NULL
x[CSHeap::NEXT] ||= CSHeap::NULL x[CsHeap::NEXT] ||= CsHeap::NULL
end end
heap.dump() heap.dump()
end end
@ -31,8 +31,8 @@ module CeleryScript
def allocate(h, s, parentAddr) def allocate(h, s, parentAddr)
addr = h.allot(s[:kind]) addr = h.allot(s[:kind])
h.put(addr, CSHeap::PARENT, parentAddr) h.put(addr, CsHeap::PARENT, parentAddr)
h.put(addr, CSHeap::COMMENT, s[:comment]) if s[:comment] h.put(addr, CsHeap::COMMENT, s[:comment]) if s[:comment]
iterate_over_body(h, s, addr) iterate_over_body(h, s, addr)
iterate_over_args(h, s, addr) iterate_over_args(h, s, addr)
addr addr
@ -44,7 +44,7 @@ module CeleryScript
.map do |key| .map do |key|
v = s[:args][key] v = s[:args][key]
if (is_celery_script(v)) if (is_celery_script(v))
k = CSHeap::LINK + key.to_s k = CsHeap::LINK + key.to_s
h.put(parentAddr, k, allocate(h, v, parentAddr)) h.put(parentAddr, k, allocate(h, v, parentAddr))
else else
h.put(parentAddr, key, v) h.put(parentAddr, key, v)
@ -64,12 +64,12 @@ module CeleryScript
is_head = index == 0 is_head = index == 0
# BE CAREFUL EDITING THIS LINE, YOU MIGHT BREAK `BODY` NODES: # BE CAREFUL EDITING THIS LINE, YOU MIGHT BREAK `BODY` NODES:
heap # See note above! heap # See note above!
.put(previous_address, CSHeap::BODY, previous_address + 1) if is_head .put(previous_address, CsHeap::BODY, previous_address + 1) if is_head
my_heap_address = allocate(heap, canonical_list[index], previous_address) my_heap_address = allocate(heap, canonical_list[index], previous_address)
prev_next_key = is_head ? CSHeap::NULL : my_heap_address prev_next_key = is_head ? CsHeap::NULL : my_heap_address
heap.put(previous_address, CSHeap::NEXT, prev_next_key) heap.put(previous_address, CsHeap::NEXT, prev_next_key)
recurse_into_body(heap, canonical_list, my_heap_address, index + 1) recurse_into_body(heap, canonical_list, my_heap_address, index + 1)
end end

View File

@ -2,10 +2,11 @@
# Listens to *ALL* incoming logs and stores them to the DB. # Listens to *ALL* incoming logs and stores them to the DB.
# Also handles throttling. # Also handles throttling.
class LogService < AbstractServiceRunner class LogService < AbstractServiceRunner
T = ThrottlePolicy::TimePeriod THROTTLE_POLICY = ThrottlePolicy.new(name, {
THROTTLE_POLICY = ThrottlePolicy.new T.new(1.minute) => 0.5 * 1_000, 1.minute => 0.5 * 1_000,
T.new(1.hour) => 0.5 * 10_000, 1.hour => 0.5 * 10_000,
T.new(1.day) => 0.5 * 100_000 1.day => 0.5 * 100_000,
})
LOG_TPL = Rails.env.test? ? LOG_TPL = Rails.env.test? ?
"\e[32m.\e[0m" : "FBOS LOG (device_%s): %s\n" "\e[32m.\e[0m" : "FBOS LOG (device_%s): %s\n"

View File

@ -4,10 +4,10 @@
class TelemetryService < AbstractServiceRunner class TelemetryService < AbstractServiceRunner
MESSAGE = "TELEMETRY MESSAGE FROM %s" MESSAGE = "TELEMETRY MESSAGE FROM %s"
FAILURE = "FAILED TELEMETRY MESSAGE FROM %s" FAILURE = "FAILED TELEMETRY MESSAGE FROM %s"
THROTTLE_POLICY = ThrottlePolicy.new({ THROTTLE_POLICY = ThrottlePolicy.new(name, {
ThrottlePolicy::TimePeriod.new(1.minute) => 25, 1.minute => 25,
ThrottlePolicy::TimePeriod.new(1.hour) => 250, 1.hour => 250,
ThrottlePolicy::TimePeriod.new(1.day) => 1500, 1.day => 1500,
}) })
def process(delivery_info, payload) def process(delivery_info, payload)

View File

@ -1,27 +1,30 @@
# Handles devices that spin out of control and send too many logs to the server. # Handles devices that spin out of control and send too many logs to the server.
# Class Hierarchy: # Class Hierarchy:
# ThrottlePolicy has => Rules creates => Violation # ThrottlePolicy
# Violation has => Rule has => TimePeriod # \
# +----> Rule --> TimePeriod
# |\
# | `--> Rule --> TimePeriod
# \_
# `-> Rule --> TimePeriod
class ThrottlePolicy class ThrottlePolicy
attr_reader :rules attr_reader :rules
# Dictionary<TimePeriod, Integer> # Dictionary<TimePeriod, Integer>
def initialize(policy_rules) def initialize(namespace, rule_map, now = Time.now)
@rules = policy_rules.map { |rule_set| Rule.new(*rule_set) } @rules = rule_map
.map { |(period, limit)| Rule.new(namespace, period, limit, now) }
end end
def track(unique_id, now = Time.now) def track(unique_id, now = Time.now)
rules.each { |r| r.time_period.record_event(unique_id, now) } rules.each { |r| r.record_event(unique_id, now) }
end end
# If throttled, returns the timeperiod when device will be unthrottled # If throttled, returns the timeperiod when device will be unthrottled
# returns nil if not throttled # returns nil if not throttled
def is_throttled(unique_id) def is_throttled(unique_id)
rules rules
.map do |rule| .map { |rule| rule.violation?(unique_id) }
is_violation = rule.time_period.usage_count_for(unique_id) > rule.limit
is_violation ? Violation.new(rule) : nil
end
.compact .compact
.max .max
end end

View File

@ -3,8 +3,21 @@ class ThrottlePolicy
class Rule class Rule
attr_reader :time_period, :limit attr_reader :time_period, :limit
def initialize(time_period, limit) def initialize(namespace, time_period, limit, now = Time.now)
@time_period, @limit = time_period, limit @time_period = ThrottlePolicy::TimePeriod.new(namespace, time_period, now)
@limit = limit
end
# returns the timeperiod when device will be
# unthrottled. returns `nil` if not throttled
def violation?(unique_id)
if (time_period.usage_count_for(unique_id) > limit)
Violation.new(self)
end
end
def record_event(unique_id, now)
time_period.record_event(unique_id, now)
end end
end end
end end

View File

@ -10,48 +10,66 @@
# the initiator ID. # the initiator ID.
class ThrottlePolicy class ThrottlePolicy
class TimePeriod class TimePeriod
attr_reader :time_unit, attr_reader :time_unit, :current_period
:current_period, # Slice time into fixed size windows
:entries
def initialize(active_support_duration, now = Time.now) def initialize(namespace, duration, now = Time.now)
@time_unit = active_support_duration @time_unit = duration
reset_everything now @namespace = namespace
reset_everything(now)
end end
def record_event(unique_id, now = Time.now) def record_event(unique_id, now = Time.now)
period = calculate_period(now) period = calculate_period(now)
case period <=> current_period case period <=> current_period
when -1 then return # Out of date- don't record. when -1 then return # Out of date- don't record.
when 0 then increment_count_for(unique_id) # Right on schedule. when 0 then increment_count_for(unique_id) # Right on schedule.
when 1 then reset_everything(now) # Clear out old data. when 1 then reset_everything(now) # Clear out old data.
end end
end end
def usage_count_for(unique_id) def usage_count_for(unique_id)
@entries[unique_id] || 0 fetch(unique_id)
end end
def when_does_next_period_start? def when_does_next_period_start?
Time.at(current_period * time_unit.to_i) + time_unit Time.at(current_period * time_unit.to_i) + time_unit
end end
private private
def reset_everything(now) def reset_everything(now)
@current_period = calculate_period(now) @current_period = calculate_period(now)
@entries = {} reset_cache
end end
def increment_count_for(unique_id) def increment_count_for(unique_id)
@entries[unique_id] ||= 0 incr(unique_id)
@entries[unique_id] += 1
end end
# Returns integer representation of current clock period # Returns integer representation of current clock period
def calculate_period(time) def calculate_period(time)
(time.to_i / @time_unit) (time.to_i / @time_unit)
end end
def redis
Rails.cache.redis
end
def cache_key(unique_id)
[@namespace, current_period.to_i, unique_id].join(":")
end
def incr(unique_id)
redis.incr(cache_key(unique_id))
end
def fetch(unique_id)
(redis.get(cache_key(unique_id)) || "0").to_i
end
def reset_cache
keys = redis.keys([@namespace, "*"].join(":"))
redis.del(*keys) unless keys.empty?
end
end end
end end

View File

@ -1,6 +1,7 @@
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true self.abstract_class = true
after_save :maybe_broadcast, on: [:create, :update] after_create :maybe_broadcast
after_update :maybe_broadcast
after_destroy :maybe_broadcast after_destroy :maybe_broadcast
class << self class << self
@ -37,7 +38,7 @@ class ApplicationRecord < ActiveRecord::Base
def self.auto_sync_debounce def self.auto_sync_debounce
@auto_sync_paused = true @auto_sync_paused = true
result = yield result = yield
result.update_attributes!(updated_at: Time.now) result.update!(updated_at: Time.now)
@auto_sync_paused = false @auto_sync_paused = false
result.broadcast! result.broadcast!
result result
@ -94,7 +95,7 @@ class ApplicationRecord < ActiveRecord::Base
def manually_sync! def manually_sync!
device && (device.auto_sync_transaction do device && (device.auto_sync_transaction do
update_attributes!(updated_at: Time.now) update!(updated_at: Time.now)
end) end)
self self
end end

View File

@ -118,8 +118,8 @@ class Device < ApplicationRecord
end_t = violation.ends_at end_t = violation.ends_at
# Some log validation errors will result in until_time being `nil`. # Some log validation errors will result in until_time being `nil`.
if (throttled_until.nil? || end_t > throttled_until) if (throttled_until.nil? || end_t > throttled_until)
reload.update_attributes!(throttled_until: end_t, reload.update!(throttled_until: end_t,
throttled_at: Time.now) throttled_at: Time.now)
refresh_cache refresh_cache
cooldown = end_t.in_time_zone(self.timezone || "UTC").strftime("%I:%M%p") cooldown = end_t.in_time_zone(self.timezone || "UTC").strftime("%I:%M%p")
info = [violation.explanation, cooldown] info = [violation.explanation, cooldown]
@ -131,7 +131,7 @@ class Device < ApplicationRecord
if throttled_until.present? if throttled_until.present?
old_time = throttled_until old_time = throttled_until
reload # <= WHY!?! TODO: Find out why it crashes without this. reload # <= WHY!?! TODO: Find out why it crashes without this.
.update_attributes!(throttled_until: nil, throttled_at: nil) .update!(throttled_until: nil, throttled_at: nil)
refresh_cache refresh_cache
cooldown_notice(THROTTLE_OFF, old_time, "info") cooldown_notice(THROTTLE_OFF, old_time, "info")
end end
@ -203,7 +203,7 @@ class Device < ApplicationRecord
last_sent_at = device.mqtt_rate_limit_email_sent_at || 4.years.ago last_sent_at = device.mqtt_rate_limit_email_sent_at || 4.years.ago
if last_sent_at < 1.day.ago if last_sent_at < 1.day.ago
device.update_attributes!(mqtt_rate_limit_email_sent_at: Time.now) device.update!(mqtt_rate_limit_email_sent_at: Time.now)
device.tell(TOO_MANY_CONNECTIONS, ["fatal_email"]) device.tell(TOO_MANY_CONNECTIONS, ["fatal_email"])
end end
end end

View File

@ -40,7 +40,7 @@ class FarmwareInstallation < ApplicationRecord
known_error = KNOWN_PROBLEMS[error.class] known_error = KNOWN_PROBLEMS[error.class]
description = \ description = \
known_error || (OTHER_PROBLEM % error.class) known_error || (OTHER_PROBLEM % error.class)
update_attributes!(package_error: description, update!(package_error: description,
package: nil) package: nil)
unless known_error.present? unless known_error.present?
raise error raise error
@ -54,7 +54,7 @@ class FarmwareInstallation < ApplicationRecord
string = string_io.read(MAX_JSON_SIZE) string = string_io.read(MAX_JSON_SIZE)
json = JSON.parse(string) json = JSON.parse(string)
pkg_name = json.fetch("package") pkg_name = json.fetch("package")
update_attributes!(package: pkg_name, package_error: nil) update!(package: pkg_name, package_error: nil)
rescue => error rescue => error
maybe_recover_from_fetch_error(error) maybe_recover_from_fetch_error(error)
end end

View File

@ -3,7 +3,8 @@ class FbosConfig < ApplicationRecord
class MissingSerial < StandardError; end class MissingSerial < StandardError; end
belongs_to :device belongs_to :device
after_save :maybe_sync_nerves, on: [:create, :update] after_create :maybe_sync_nerves
after_update :maybe_sync_nerves
FIRMWARE_HARDWARE = [ FIRMWARE_HARDWARE = [
NOT_SET = nil, NOT_SET = nil,

View File

@ -28,7 +28,7 @@ class GlobalConfig < ApplicationRecord
# Remove it if the demo tour does not require it. # Remove it if the demo tour does not require it.
"MQTT_WS" => SessionToken::MQTT_WS, "MQTT_WS" => SessionToken::MQTT_WS,
}.map do |(key, value)| }.map do |(key, value)|
self.find_or_create_by(key: key).update_attributes(key: key, value: value) self.find_or_create_by(key: key).update(key: key, value: value)
end end
# Memoized version of every GlobalConfig, with key/values layed out in a hash. # Memoized version of every GlobalConfig, with key/values layed out in a hash.

View File

@ -40,16 +40,9 @@ class Image < ApplicationRecord
# Worst case scenario for 1280x1280 BMP. # Worst case scenario for 1280x1280 BMP.
GCS_BUCKET_NAME = ENV["GCS_BUCKET"] GCS_BUCKET_NAME = ENV["GCS_BUCKET"]
# ========= DEPRECATED PAPERCLIP STUFF =========
# has_attached_file :attachment, CONFIG
# validates_attachment_content_type :attachment,
# content_type: CONTENT_TYPES
# ========= /DEPRECATED PAPERCLIP STUFF ========
has_one_attached :attachment has_one_attached :attachment
def set_attachment_by_url(url) def set_attachment_by_url(url)
# File
# URI::HTTPS
attachment.attach(io: open(url), filename: "image_#{self.id}") attachment.attach(io: open(url), filename: "image_#{self.id}")
self.attachment_processed_at = Time.now self.attachment_processed_at = Time.now
self self

View File

@ -11,7 +11,9 @@ class ToolSlot < Point
MIN_PULLOUT = PULLOUT_DIRECTIONS.min MIN_PULLOUT = PULLOUT_DIRECTIONS.min
PULLOUT_ERR = "must be a value between #{MIN_PULLOUT} and #{MAX_PULLOUT}. "\ PULLOUT_ERR = "must be a value between #{MIN_PULLOUT} and #{MAX_PULLOUT}. "\
"%{value} is not valid." "%{value} is not valid."
IN_USE = "already in use by another tool slot" IN_USE = "already in use by another tool slot. "\
"Please un-assign the tool from its current slot"\
" before reassigning."
belongs_to :tool belongs_to :tool
validates_uniqueness_of :tool, validates_uniqueness_of :tool,

View File

@ -26,7 +26,7 @@ module Auth
end end
def execute def execute
@user.update_attributes(agreed_to_terms_at: Time.now) if agree_to_terms @user.update(agreed_to_terms_at: Time.now) if agree_to_terms
SessionToken.as_json(@user, aud, fbos_version) SessionToken.as_json(@user, aud, fbos_version)
end end

View File

@ -1,7 +1,7 @@
module Auth module Auth
# The API supports a number of authentication strategies (Cookies, Bot token, # The API supports a number of authentication strategies (Cookies, Bot token,
# JWT). This service helps determine which auth strategy to use. # JWT). This service helps determine which auth strategy to use.
class FromJWT < Mutations::Command class FromJwt < Mutations::Command
required { string :jwt } required { string :jwt }
def execute def execute

View File

@ -5,7 +5,7 @@ module Configs
GOOD = 5556 GOOD = 5556
required do required do
duck :target, methods: [:update_attributes!] duck :target, methods: [:update!]
duck :update_attrs, methods: [:deep_symbolize_keys] duck :update_attrs, methods: [:deep_symbolize_keys]
end end

View File

@ -10,7 +10,7 @@ module DeviceCerts
SendNervesHubInfoJob.perform_later(device_id: device.id, SendNervesHubInfoJob.perform_later(device_id: device.id,
serial_number: serial_number, serial_number: serial_number,
tags: tags) tags: tags)
return device.update_attributes!(serial_number: serial_number) && device return device.update!(serial_number: serial_number) && device
end end
end end
end end

View File

@ -24,7 +24,7 @@ module Devices
# when we were using MongoDB. This can be # when we were using MongoDB. This can be
# safely removed now. - RC 11-APR-19 # safely removed now. - RC 11-APR-19
old_device = user.device old_device = user.device
user.update_attributes!(device_id: device.id) # Detach from old one. user.update!(device_id: device.id) # Detach from old one.
# Remove userless devices. # Remove userless devices.
old_device.destroy! if old_device && device.users.count < 1 old_device.destroy! if old_device && device.users.count < 1
end end

View File

@ -7,7 +7,7 @@ module Devices
def execute def execute
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
user.update_attributes!(device: Devices::Create.run!(user: user)) user.update!(device: Devices::Create.run!(user: user))
device.destroy! if device.reload.users.count < 1 device.destroy! if device.reload.users.count < 1
end end
true true

View File

@ -20,7 +20,7 @@ module Devices
def run_it def run_it
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
device.update_attributes!(name: "FarmBot") device.update!(name: "FarmBot")
Device::SINGULAR_RESOURCES.keys.map do |resource| Device::SINGULAR_RESOURCES.keys.map do |resource|
device.send(resource).destroy! device.send(resource).destroy!
end end

View File

@ -2,14 +2,14 @@ module Devices
module Seeders module Seeders
class AbstractExpress < AbstractGenesis class AbstractExpress < AbstractGenesis
def settings_device_name def settings_device_name
device.update_attributes!(name: "FarmBot Express") device.update!(name: "FarmBot Express")
end end
def sensors_soil_sensor; end def sensors_soil_sensor; end
def sensors_tool_verification; end def sensors_tool_verification; end
def settings_enable_encoders def settings_enable_encoders
device.firmware_config.update_attributes!(encoder_enabled_x: 0, device.firmware_config.update!(encoder_enabled_x: 0,
encoder_enabled_y: 0, encoder_enabled_y: 0,
encoder_enabled_z: 0) encoder_enabled_z: 0)
end end
@ -17,7 +17,7 @@ module Devices
def settings_firmware def settings_firmware
device device
.fbos_config .fbos_config
.update_attributes!(firmware_hardware: FbosConfig::EXPRESS_K10) .update!(firmware_hardware: FbosConfig::EXPRESS_K10)
end end
def tool_slots_slot_1 def tool_slots_slot_1
@ -91,11 +91,11 @@ module Devices
def sequences_unmount_tool; end def sequences_unmount_tool; end
def settings_default_map_size_y def settings_default_map_size_y
device.web_app_config.update_attributes!(map_size_y: 1_200) device.web_app_config.update!(map_size_y: 1_200)
end end
def settings_hide_sensors def settings_hide_sensors
device.web_app_config.update_attributes!(hide_sensors: true) device.web_app_config.update!(hide_sensors: true)
end end
private private

View File

@ -18,11 +18,11 @@ module Devices
end end
def settings_device_name def settings_device_name
device.update_attributes!(name: "FarmBot Genesis") device.update!(name: "FarmBot Genesis")
end end
def settings_enable_encoders def settings_enable_encoders
device.firmware_config.update_attributes!(encoder_enabled_x: 1, device.firmware_config.update!(encoder_enabled_x: 1,
encoder_enabled_y: 1, encoder_enabled_y: 1,
encoder_enabled_z: 1) encoder_enabled_z: 1)
end end
@ -146,15 +146,15 @@ module Devices
def settings_firmware def settings_firmware
device device
.fbos_config .fbos_config
.update_attributes!(firmware_hardware: FbosConfig::FARMDUINO) .update!(firmware_hardware: FbosConfig::FARMDUINO)
end end
def settings_default_map_size_x def settings_default_map_size_x
device.web_app_config.update_attributes!(map_size_x: 2_900) device.web_app_config.update!(map_size_x: 2_900)
end end
def settings_default_map_size_y def settings_default_map_size_y
device.web_app_config.update_attributes!(map_size_y: 1_400) device.web_app_config.update!(map_size_y: 1_400)
end end
def pin_bindings_button_1 def pin_bindings_button_1

View File

@ -72,7 +72,7 @@ module Devices
end end
def settings_hide_sensors def settings_hide_sensors
device.web_app_config.update_attributes!(hide_sensors: false) device.web_app_config.update!(hide_sensors: false)
end end
def plants def plants

View File

@ -55,10 +55,10 @@ module Devices
.map { |p| p.merge(device: device) } .map { |p| p.merge(device: device) }
.map { |p| Alerts::Create.run!(p) } .map { |p| Alerts::Create.run!(p) }
device device
.update_attributes!(fbos_version: READ_COMMENT_ABOVE) .update!(fbos_version: READ_COMMENT_ABOVE)
device device
.web_app_config .web_app_config
.update_attributes!(discard_unsaved: true) .update!(discard_unsaved: true)
end end
end end
end end

View File

@ -2,15 +2,15 @@ module Devices
module Seeders module Seeders
class ExpressXlOneZero < AbstractExpress class ExpressXlOneZero < AbstractExpress
def settings_device_name def settings_device_name
device.update_attributes!(name: "FarmBot Express XL") device.update!(name: "FarmBot Express XL")
end end
def settings_default_map_size_x def settings_default_map_size_x
device.web_app_config.update_attributes!(map_size_x: 6_000) device.web_app_config.update!(map_size_x: 6_000)
end end
def settings_default_map_size_y def settings_default_map_size_y
device.web_app_config.update_attributes!(map_size_y: 2_400) device.web_app_config.update!(map_size_y: 2_400)
end end
end end
end end

View File

@ -4,7 +4,7 @@ module Devices
def settings_firmware def settings_firmware
device device
.fbos_config .fbos_config
.update_attributes!(firmware_hardware: FbosConfig::FARMDUINO_K14) .update!(firmware_hardware: FbosConfig::FARMDUINO_K14)
end end
end end
end end

View File

@ -12,7 +12,7 @@ module Devices
def settings_firmware def settings_firmware
device device
.fbos_config .fbos_config
.update_attributes!(firmware_hardware: FbosConfig::ARDUINO) .update!(firmware_hardware: FbosConfig::ARDUINO)
end end
def peripherals_lighting; end def peripherals_lighting; end

View File

@ -4,19 +4,19 @@ module Devices
def settings_firmware def settings_firmware
device device
.fbos_config .fbos_config
.update_attributes!(firmware_hardware: FbosConfig::FARMDUINO_K14) .update!(firmware_hardware: FbosConfig::FARMDUINO_K14)
end end
def settings_device_name def settings_device_name
device.update_attributes!(name: "FarmBot Genesis XL") device.update!(name: "FarmBot Genesis XL")
end end
def settings_default_map_size_x def settings_default_map_size_x
device.web_app_config.update_attributes!(map_size_x: 5_900) device.web_app_config.update!(map_size_x: 5_900)
end end
def settings_default_map_size_y def settings_default_map_size_y
device.web_app_config.update_attributes!(map_size_y: 2_900) device.web_app_config.update!(map_size_y: 2_900)
end end
end end
end end

View File

@ -2,6 +2,7 @@ module Devices
class Sync < Mutations::Command class Sync < Mutations::Command
SEL = "SELECT id, updated_at FROM" SEL = "SELECT id, updated_at FROM"
WHERE = "WHERE device_id = " WHERE = "WHERE device_id = "
FORMAT = "%Y-%m-%d %H:%M:%S.%5N"
def self.basic_query(plural_resource, where = WHERE) def self.basic_query(plural_resource, where = WHERE)
[SEL, plural_resource, where].join(" ") [SEL, plural_resource, where].join(" ")

View File

@ -21,7 +21,7 @@ module Devices
def execute def execute
p = inputs.except(:device).merge(mounted_tool_data) p = inputs.except(:device).merge(mounted_tool_data)
device.update_attributes!(p) device.update!(p)
device device
end end

View File

@ -56,7 +56,7 @@ module FarmEvents
FarmEvents => ->() { farm_event }, FarmEvents => ->() { farm_event },
Regimens => ->() { regimen }, Regimens => ->() { regimen },
} }
options.fetch(self.class.parent).call() options.fetch(self.class.module_parent).call()
end end
end end
end end

View File

@ -33,7 +33,7 @@ module FarmEvents
FarmEvent.auto_sync_debounce do FarmEvent.auto_sync_debounce do
FarmEvent.transaction do FarmEvent.transaction do
handle_body_field handle_body_field
farm_event.update_attributes!(p) farm_event.update!(p)
farm_event farm_event
end end
end end

View File

@ -10,7 +10,7 @@ module FarmwareEnvs
end end
def execute def execute
farmware_env.update_attributes!(inputs.except(:farmware_env)) && farmware_env farmware_env.update!(inputs.except(:farmware_env)) && farmware_env
end end
end end
end end

View File

@ -14,7 +14,7 @@ module PasswordResets
end end
def execute def execute
user.update_attributes!(password: password, user.update!(password: password,
password_confirmation: password_confirmation) password_confirmation: password_confirmation)
Auth::CreateToken.run!(email: user.email, Auth::CreateToken.run!(email: user.email,
password: password, password: password,

View File

@ -11,7 +11,7 @@ module Peripherals
end end
def execute def execute
peripheral.update_attributes!(inputs.except(:peripheral, :device)) peripheral.update!(inputs.except(:peripheral, :device))
peripheral peripheral
end end
end end

View File

@ -21,7 +21,7 @@ module PinBindings
def execute def execute
x = inputs.except(:pin_binding, :device) x = inputs.except(:pin_binding, :device)
pin_binding.update_attributes!(x) && pin_binding pin_binding.update!(x) && pin_binding
end end
end end
end end

View File

@ -16,7 +16,7 @@ module PlantTemplates
end end
def execute def execute
plant_template.update_attributes!(update_params) plant_template.update!(update_params)
plant_template plant_template
end end

View File

@ -15,7 +15,7 @@ module PointGroups
end end
def validate def validate
validate_point_ids if point_ids.any? validate_point_ids if point_ids.present?
validate_sort_type validate_sort_type
end end
@ -24,7 +24,7 @@ module PointGroups
PointGroup.transaction do PointGroup.transaction do
PointGroupItem.transaction do PointGroupItem.transaction do
maybe_reconcile_points maybe_reconcile_points
point_group.update_attributes!(update_attributes) point_group.update!(update_attributes)
point_group.reload # <= Because PointGroupItem caching? point_group.reload # <= Because PointGroupItem caching?
end end
end end
@ -38,19 +38,26 @@ module PointGroups
end end
def maybe_reconcile_points def maybe_reconcile_points
# Nil means "ignore"
# [] means "reset"
return if point_ids.nil?
# STEP 0: Setup # STEP 0: Setup
@old_point_ids = Set.new(point_group.point_group_items.pluck(:id)) @old_point_ids = Set.new(point_group.point_group_items.pluck(:point_id))
@new_point_ids = Set.new(point_ids) @new_point_ids = Set.new(point_ids)
@dont_delete = @new_point_ids & @old_point_ids @dont_delete = @new_point_ids & @old_point_ids
@do_delete = (@old_point_ids - @dont_delete).to_a @do_delete = (@old_point_ids - @dont_delete).to_a
# STEP 1: "Garbage Collection" of PGIs that are no longer used. # STEP 1: "Garbage Collection" of PGIs that are no longer used.
PointGroupItem.where(id: @do_delete).map(&:destroy!) PointGroupItem
.where(point_group_id: point_group.id)
.where(point_id: @do_delete)
.destroy_all
# STEP 2: Create missing PGIs # STEP 2: Create missing PGIs
@do_create = (@new_point_ids - @dont_delete) @do_create = (@new_point_ids - @dont_delete)
PointGroupItem.create!(@do_create.to_a.uniq.map do |id| PointGroupItem.create!(@do_create.to_a.uniq.map do |point_id|
{ point_id: id, point_group_id: point_group.id } { point_id: point_id, point_group_id: point_group.id }
end) end)
end end
end end

View File

@ -1,5 +1,5 @@
require_relative "../../lib/hstore_filter" require_relative "../../lib/mutations/hstore_filter.rb"
# WHY??? ^
module Points module Points
class Create < Mutations::Command class Create < Mutations::Command
# WHY 1000?: # WHY 1000?:

View File

@ -73,7 +73,7 @@ module Points
# a fresh session_id, the frontend will # a fresh session_id, the frontend will
# think it is an "echo" and cancel it out. # think it is an "echo" and cancel it out.
# """ - Rick # """ - Rick
x.update_attributes!(updated_at: Time.now) x.update!(updated_at: Time.now)
x.broadcast!(SecureRandom.uuid) x.broadcast!(SecureRandom.uuid)
end end
end end

View File

@ -1,47 +1,47 @@
require_relative "../../lib/hstore_filter" # require_relative "../../lib/hstore_filter"
# WHY??? ^ # WHY??? ^
module Points module Points
class Query < Mutations::Command class Query < Mutations::Command
H_QUERY = "meta -> :key = :value" H_QUERY = "meta -> :key = :value"
required do required do
duck :points, method: [:where] duck :points, method: [:where]
end end
optional do optional do
float :radius float :radius
float :x float :x
float :y float :y
float :z float :z
hstore :meta hstore :meta
string :name string :name
string :pointer_type, in: Point::POINTER_KINDS string :pointer_type, in: Point::POINTER_KINDS
string :plant_stage, in: CeleryScriptSettingsBag::PLANT_STAGES string :plant_stage, in: CeleryScriptSettingsBag::PLANT_STAGES
string :openfarm_slug string :openfarm_slug
end end
def execute def execute
search_results search_results
end end
def search_results def search_results
@search_results ||= conditions.reduce(points) do |collection, query| @search_results ||= conditions.reduce(points) do |collection, query|
collection.where(query) collection.where(query)
end
end
def conditions
@conditions ||= regular_conditions + meta_conditions
end
def meta_conditions
@meta_conditions ||= (meta || {}).map do |(k,v)|
[H_QUERY, {key: k, value: v}]
end
end
def regular_conditions
@regular_conditions ||= [inputs.except(:points, :meta)]
end end
end end
def conditions
@conditions ||= regular_conditions + meta_conditions
end
def meta_conditions
@meta_conditions ||= (meta || {}).map do |(k, v)|
[H_QUERY, { key: k, value: v }]
end
end
def regular_conditions
@regular_conditions ||= [inputs.except(:points, :meta)]
end
end
end end

View File

@ -1,24 +1,24 @@
require_relative "../../lib/hstore_filter" # require_relative "../../lib/hstore_filter"
module Points module Points
class Update < Mutations::Command class Update < Mutations::Command
required do required do
model :device, class: Device model :device, class: Device
model :point, class: Point model :point, class: Point
end end
optional do optional do
integer :tool_id, nils: true, empty_is_nil: true integer :tool_id, nils: true, empty_is_nil: true
float :x float :x
float :y float :y
float :z float :z
float :radius float :radius
string :name string :name
string :openfarm_slug string :openfarm_slug
integer :pullout_direction, in: ToolSlot::PULLOUT_DIRECTIONS integer :pullout_direction, in: ToolSlot::PULLOUT_DIRECTIONS
string :plant_stage, in: CeleryScriptSettingsBag::PLANT_STAGES string :plant_stage, in: CeleryScriptSettingsBag::PLANT_STAGES
time :planted_at time :planted_at
hstore :meta hstore :meta
boolean :gantry_mounted boolean :gantry_mounted
end end
@ -27,10 +27,10 @@ module Points
end end
def execute def execute
Point.transaction { point.update_attributes!(inputs.except(:point)) && point } Point.transaction { point.update!(inputs.except(:point)) && point }
end end
private private
def new_tool_id? def new_tool_id?
raw_inputs.key?("tool_id") raw_inputs.key?("tool_id")

View File

@ -27,7 +27,7 @@ module Regimens
RegimenItem.new(ri).tap { |r| r.validate! } RegimenItem.new(ri).tap { |r| r.validate! }
end end
handle_body_field handle_body_field
regimen.update_attributes!(inputs.slice(:name, :color, :regimen_items)) regimen.update!(inputs.slice(:name, :color, :regimen_items))
regimen regimen
end end
end end

View File

@ -9,7 +9,7 @@ module SavedGardens
end end
def execute def execute
saved_garden.update_attributes!(inputs.except(:saved_garden)) saved_garden.update!(inputs.except(:saved_garden))
saved_garden saved_garden
end end
end end

View File

@ -9,7 +9,7 @@ module Sensors
end end
def execute def execute
sensor.update_attributes!(inputs.except(:sensor)) sensor.update!(inputs.except(:sensor))
sensor sensor
end end
end end

View File

@ -40,7 +40,7 @@ module Sequences
Sequence.auto_sync_debounce do Sequence.auto_sync_debounce do
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
sequence.migrated_nodes = true sequence.migrated_nodes = true
sequence.update_attributes!(inputs.except(*BLACKLIST)) sequence.update!(inputs.except(*BLACKLIST))
CeleryScript::StoreCelery.run!(sequence: sequence, CeleryScript::StoreCelery.run!(sequence: sequence,
args: args, args: args,
body: body) body: body)

View File

@ -9,7 +9,7 @@ module Tools
end end
def execute def execute
tool.update_attributes!(inputs.except(:tool)) && tool tool.update!(inputs.except(:tool)) && tool
end end
end end
end end

View File

@ -3,7 +3,7 @@ module Users
required { model :user, class: User } required { model :user, class: User }
def execute def execute
user.update_attributes!(confirmed_at: Time.now, user.update!(confirmed_at: Time.now,
email: user.unconfirmed_email, email: user.unconfirmed_email,
unconfirmed_email: nil) unconfirmed_email: nil)
fbos_vers = Gem::Version.new("99.9.9") # Not relevant here, stubbing out. fbos_vers = Gem::Version.new("99.9.9") # Not relevant here, stubbing out.

View File

@ -21,7 +21,7 @@ module Users
def execute def execute
maybe_perform_password_reset maybe_perform_password_reset
user.update_attributes!(calculated_update) user.update!(calculated_update)
user.reload user.reload
end end

View File

@ -8,7 +8,7 @@ module WebcamFeeds
end end
def execute def execute
webcam_feed.update_attributes!(inputs.except(:webcam_feed)) && webcam_feed webcam_feed.update!(inputs.except(:webcam_feed)) && webcam_feed
end end
end end
end end

View File

@ -1,6 +1,6 @@
require_relative "../app/models/transport.rb" require_relative "../app/models/transport.rb"
require File.expand_path("../boot", __FILE__) require File.expand_path("../boot", __FILE__)
require_relative "../app/lib/celery_script/csheap" require_relative "../app/lib/celery_script/cs_heap"
require "rails/all" require "rails/all"
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
@ -14,11 +14,13 @@ module FarmBot
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0") REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
gcs_enabled = gcs_enabled =
%w[ GOOGLE_CLOUD_KEYFILE_JSON GCS_PROJECT GCS_BUCKET ].all? { |s| ENV.key? s } %w[ GOOGLE_CLOUD_KEYFILE_JSON GCS_PROJECT GCS_BUCKET ].all? { |s| ENV.key? s }
config.load_defaults 6.0
config.active_storage.service = gcs_enabled ? config.active_storage.service = gcs_enabled ?
:google : :local :google : :local
config.cache_store = :redis_cache_store, { url: REDIS_URL } config.cache_store = :redis_cache_store, { url: REDIS_URL }
config.middleware.use Rack::Attack config.middleware.use Rack::Attack
config.active_record.schema_format = :sql config.active_record.schema_format = :sql
config.active_record.belongs_to_required_by_default = false
config.active_job.queue_adapter = :delayed_job config.active_job.queue_adapter = :delayed_job
config.action_dispatch.perform_deep_munge = false config.action_dispatch.perform_deep_munge = false
I18n.enforce_available_locales = false I18n.enforce_available_locales = false

View File

@ -10,7 +10,7 @@ class DeprecateDeviceSerialNumberTable < ActiveRecord::Migration[5.2]
def change def change
DeviceSerialNumber.preload(:devices) do |x| DeviceSerialNumber.preload(:devices) do |x|
x.device.update_attributes!(serial_number: x.serial_number) x.device.update!(serial_number: x.serial_number)
end end
end end
end end

View File

@ -29,9 +29,10 @@ if Rails.env == "development"
SensorReading, SensorReading,
FarmwareInstallation, FarmwareInstallation,
Tool, Tool,
Device,
Delayed::Job, Delayed::Job,
Delayed::Backend::ActiveRecord::Job, Delayed::Backend::ActiveRecord::Job,
Fragment,
Device,
].map(&:delete_all) ].map(&:delete_all)
Users::Create.run!(name: "Test", Users::Create.run!(name: "Test",
email: "test@test.com", email: "test@test.com",
@ -43,7 +44,7 @@ if Rails.env == "development"
User.update_all(confirmed_at: Time.now, User.update_all(confirmed_at: Time.now,
agreed_to_terms_at: Time.now) agreed_to_terms_at: Time.now)
u = User.last u = User.last
u.update_attributes(device: Devices::Create.run!(user: u)) u.update(device: Devices::Create.run!(user: u))
# === Parameterized Sequence stuff # === Parameterized Sequence stuff
json = JSON.parse(File.read("spec/lib/celery_script/ast_fixture5.json"), symbolize_names: true) json = JSON.parse(File.read("spec/lib/celery_script/ast_fixture5.json"), symbolize_names: true)
Sequences::Create.run!(json, device: u.device) Sequences::Create.run!(json, device: u.device)

View File

@ -13,4 +13,5 @@ export const fakeDesignerState = (): DesignerState => ({
chosenLocation: { x: undefined, y: undefined, z: undefined }, chosenLocation: { x: undefined, y: undefined, z: undefined },
currentPoint: undefined, currentPoint: undefined,
openedSavedGarden: undefined, openedSavedGarden: undefined,
tryGroupSortType: undefined,
}); });

View File

@ -6,7 +6,9 @@ jest.mock("../../../config_storage/actions", () => ({
import * as React from "react"; import * as React from "react";
import { mount, shallow } from "enzyme"; import { mount, shallow } from "enzyme";
import { DevWidget, DevWidgetFERow, DevWidgetFBOSRow } from "../dev_widget"; import {
DevWidget, DevWidgetFERow, DevWidgetFBOSRow, DevWidgetDelModeRow
} from "../dev_widget";
import { DevSettings } from "../dev_support"; import { DevSettings } from "../dev_support";
import { setWebAppConfigValue } from "../../../config_storage/actions"; import { setWebAppConfigValue } from "../../../config_storage/actions";
@ -52,4 +54,12 @@ describe("<DevWidget />", () => {
wrapper.find("button").simulate("click"); wrapper.find("button").simulate("click");
expect(setWebAppConfigValue).toHaveBeenCalledWith("internal_use", "{}"); expect(setWebAppConfigValue).toHaveBeenCalledWith("internal_use", "{}");
}); });
it("disables delete mode", () => {
delete mockDevSettings[DevSettings.FUTURE_FE_FEATURES];
mockDevSettings[DevSettings.QUICK_DELETE_MODE] = "true";
const wrapper = mount(<DevWidgetDelModeRow />);
wrapper.find("button").simulate("click");
expect(setWebAppConfigValue).toHaveBeenCalledWith("internal_use", "{}");
});
}); });

View File

@ -10,6 +10,7 @@ namespace devStorage {
export enum Key { export enum Key {
FUTURE_FE_FEATURES = "FUTURE_FE_FEATURES", FUTURE_FE_FEATURES = "FUTURE_FE_FEATURES",
FBOS_VERSION_OVERRIDE = "FBOS_VERSION_OVERRIDE", FBOS_VERSION_OVERRIDE = "FBOS_VERSION_OVERRIDE",
QUICK_DELETE_MODE = "QUICK_DELETE_MODE",
} }
type Storage = { [K in Key]: string }; type Storage = { [K in Key]: string };
@ -64,4 +65,11 @@ export namespace DevSettings {
export const setMaxFbosVersionOverride = () => export const setMaxFbosVersionOverride = () =>
devStorage.setItem(FBOS_VERSION_OVERRIDE, MAX_FBOS_VERSION_OVERRIDE); devStorage.setItem(FBOS_VERSION_OVERRIDE, MAX_FBOS_VERSION_OVERRIDE);
export const QUICK_DELETE_MODE = devStorage.Key.QUICK_DELETE_MODE;
export const quickDeleteEnabled = () =>
!!devStorage.getItem(QUICK_DELETE_MODE);
export const enableQuickDelete = () =>
devStorage.setItem(QUICK_DELETE_MODE, "true");
export const disableQuickDelete = () =>
devStorage.removeItem(QUICK_DELETE_MODE);
} }

View File

@ -47,6 +47,22 @@ export const DevWidgetFBOSRow = () => {
</Row>; </Row>;
}; };
export const DevWidgetDelModeRow = () =>
<Row>
<Col xs={8}>
<label>
{"Enable quick delete mode"}
</label>
</Col>
<Col xs={4}>
<ToggleButton
toggleValue={DevSettings.quickDeleteEnabled()}
toggleAction={DevSettings.quickDeleteEnabled()
? DevSettings.disableQuickDelete
: DevSettings.enableQuickDelete} />
</Col>
</Row>;
export const DevWidget = ({ dispatch }: { dispatch: Function }) => export const DevWidget = ({ dispatch }: { dispatch: Function }) =>
<Widget> <Widget>
<WidgetHeader title={"Dev options"}> <WidgetHeader title={"Dev options"}>
@ -62,6 +78,7 @@ export const DevWidget = ({ dispatch }: { dispatch: Function }) =>
</WidgetHeader> </WidgetHeader>
<WidgetBody> <WidgetBody>
<DevWidgetFERow /> <DevWidgetFERow />
<DevWidgetDelModeRow />
<DevWidgetFBOSRow /> <DevWidgetFBOSRow />
</WidgetBody> </WidgetBody>
</Widget>; </Widget>;

View File

@ -974,6 +974,7 @@ export enum Actions {
CHOOSE_LOCATION = "CHOOSE_LOCATION", CHOOSE_LOCATION = "CHOOSE_LOCATION",
SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA", SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA",
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN", CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
TRY_SORT_TYPE = "TRY_SORT_TYPE",
// Regimens // Regimens
PUSH_WEEK = "PUSH_WEEK", PUSH_WEEK = "PUSH_WEEK",

View File

@ -303,6 +303,20 @@
opacity: 1; opacity: 1;
} }
} }
&.quick-del {
&:hover {
background: lighten($red, 10%) !important;
border: none;
box-shadow: inset 0px 0px 0px 4px $darkest_red !important;
&:after {
content: "x";
position: absolute;
right: 1rem;
color: $darkest_red;
font-size: 2rem;
}
}
}
} }
.block-control { .block-control {

View File

@ -1469,3 +1469,16 @@ textarea {
textarea:focus { textarea:focus {
box-shadow: 0 0 10px rgba(0,0,0,.2); box-shadow: 0 0 10px rgba(0,0,0,.2);
} }
.sort-path-info-bar {
background: lightgray;
cursor: pointer;
font-size: 1.1rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
white-space: nowrap;
line-height: 1.75rem;
&:hover {
background: darken(lightgray, 10%);
}
}

View File

@ -101,6 +101,7 @@
.farmware-button{ .farmware-button{
position: relative; position: relative;
top: 0rem; top: 0rem;
z-index: 9;
height: 0px; height: 0px;
float: right; float: right;
} }

View File

@ -209,7 +209,7 @@
} }
.farmware-list-items, .farmware-list-items,
.sequence-list-items, .sequence-list-item,
.regimen-list { .regimen-list {
button { button {
text-align: left; text-align: left;
@ -220,6 +220,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
margin-top: 0.5rem; margin-top: 0.5rem;
float: left; float: left;
cursor: pointer;
} }
} }
i { i {
@ -228,7 +229,7 @@
} }
} }
.sequence-list-items { .sequence-list-item {
margin-right: 15px; margin-right: 15px;
} }

View File

@ -1,6 +1,6 @@
let mockReleaseNoteData = {}; let mockReleaseNoteResponse = Promise.resolve({ data: "" });
jest.mock("axios", () => ({ jest.mock("axios", () => ({
get: jest.fn(() => Promise.resolve(mockReleaseNoteData)) get: jest.fn(() => mockReleaseNoteResponse)
})); }));
jest.mock("../../../api/crud", () => ({ jest.mock("../../../api/crud", () => ({
@ -53,24 +53,44 @@ describe("<FarmbotOsSettings />", () => {
}); });
it("fetches OS release notes", async () => { it("fetches OS release notes", async () => {
mockReleaseNoteData = { data: "intro\n\n# v6\n\n* note" }; mockReleaseNoteResponse = Promise.resolve({
data: "intro\n\n# v6\n\n* note"
});
const osSettings = await mount<FarmbotOsSettings>(<FarmbotOsSettings const osSettings = await mount<FarmbotOsSettings>(<FarmbotOsSettings
{...fakeProps()} />); {...fakeProps()} />);
await expect(axios.get).toHaveBeenCalledWith( await expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("RELEASE_NOTES.md")); expect.stringContaining("RELEASE_NOTES.md"));
expect(osSettings.instance().state.osReleaseNotesHeading) expect(osSettings.instance().osReleaseNotes.heading)
.toEqual("FarmBot OS v6"); .toEqual("FarmBot OS v6");
expect(osSettings.instance().state.osReleaseNotes) expect(osSettings.instance().osReleaseNotes.notes)
.toEqual("* note"); .toEqual("* note");
}); });
it("doesn't fetch OS release notes", async () => { it("doesn't fetch OS release notes", async () => {
mockReleaseNoteData = { data: "empty notes" }; mockReleaseNoteResponse = Promise.resolve({ data: "" });
const osSettings = await mount<FarmbotOsSettings>(<FarmbotOsSettings const osSettings = await mount<FarmbotOsSettings>(<FarmbotOsSettings
{...fakeProps()} />); {...fakeProps()} />);
await expect(axios.get).toHaveBeenCalledWith( await expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("RELEASE_NOTES.md")); expect.stringContaining("RELEASE_NOTES.md"));
expect(osSettings.instance().state.osReleaseNotes) expect(osSettings.instance().state.allOsReleaseNotes)
.toEqual("");
expect(osSettings.instance().osReleaseNotes.heading)
.toEqual("FarmBot OS v6");
expect(osSettings.instance().osReleaseNotes.notes)
.toEqual("Could not get release notes.");
});
it("errors while fetching OS release notes", async () => {
mockReleaseNoteResponse = Promise.reject({ error: "" });
const osSettings = await mount<FarmbotOsSettings>(<FarmbotOsSettings
{...fakeProps()} />);
await expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("RELEASE_NOTES.md"));
expect(osSettings.instance().state.allOsReleaseNotes)
.toEqual("");
expect(osSettings.instance().osReleaseNotes.heading)
.toEqual("FarmBot OS v6");
expect(osSettings.instance().osReleaseNotes.notes)
.toEqual("Could not get release notes."); .toEqual("Could not get release notes.");
}); });
@ -82,4 +102,11 @@ describe("<FarmbotOsSettings />", () => {
.simulate("change", { currentTarget: { value: newName } }); .simulate("change", { currentTarget: { value: newName } });
expect(edit).toHaveBeenCalledWith(p.deviceAccount, { name: newName }); expect(edit).toHaveBeenCalledWith(p.deviceAccount, { name: newName });
}); });
it("displays boot sequence selector", () => {
const p = fakeProps();
p.shouldDisplay = () => true;
const osSettings = shallow(<FarmbotOsSettings {...p} />);
expect(osSettings.find("BootSequenceSelector").length).toEqual(1);
});
}); });

View File

@ -29,27 +29,31 @@ const OS_RELEASE_NOTES_URL =
export class FarmbotOsSettings export class FarmbotOsSettings
extends React.Component<FarmbotOsProps, FarmbotOsState> { extends React.Component<FarmbotOsProps, FarmbotOsState> {
state = { osReleaseNotesHeading: "", osReleaseNotes: "" }; state: FarmbotOsState = { allOsReleaseNotes: "" };
componentDidMount() { componentDidMount() {
this.fetchReleaseNotes(OS_RELEASE_NOTES_URL, this.fetchReleaseNotes(OS_RELEASE_NOTES_URL);
(this.props.bot.hardware.informational_settings
.controller_version || "6").split(".")[0]);
} }
fetchReleaseNotes = (url: string, osMajorVersion: string) => { get osMajorVersion() {
return (this.props.bot.hardware.informational_settings
.controller_version || "6").split(".")[0];
}
fetchReleaseNotes = (url: string) => {
axios axios
.get<string>(url) .get<string>(url)
.then(resp => { .then(resp => this.setState({ allOsReleaseNotes: resp.data }))
const osReleaseNotes = resp.data .catch(() => this.setState({ allOsReleaseNotes: "" }));
.split("# v") }
.filter(x => x.startsWith(osMajorVersion))[0]
.split("\n\n").slice(1).join("\n"); get osReleaseNotes() {
const osReleaseNotesHeading = "FarmBot OS v" + osMajorVersion; const notes = (this.state.allOsReleaseNotes
this.setState({ osReleaseNotesHeading, osReleaseNotes }); .split("# v")
}) .filter(x => x.startsWith(this.osMajorVersion))[0] || "")
.catch(() => .split("\n\n").slice(1).join("\n") || t("Could not get release notes.");
this.setState({ osReleaseNotes: "Could not get release notes." })); const heading = "FarmBot OS v" + this.osMajorVersion;
return { heading, notes };
} }
changeBot = (e: React.FormEvent<HTMLInputElement>) => { changeBot = (e: React.FormEvent<HTMLInputElement>) => {
@ -119,8 +123,8 @@ export class FarmbotOsSettings
|| this.props.isValidFbosConfig}> || this.props.isValidFbosConfig}>
<FarmbotOsRow <FarmbotOsRow
bot={this.props.bot} bot={this.props.bot}
osReleaseNotesHeading={this.state.osReleaseNotesHeading} osReleaseNotesHeading={this.osReleaseNotes.heading}
osReleaseNotes={this.state.osReleaseNotes} osReleaseNotes={this.osReleaseNotes.notes}
dispatch={this.props.dispatch} dispatch={this.props.dispatch}
sourceFbosConfig={sourceFbosConfig} sourceFbosConfig={sourceFbosConfig}
shouldDisplay={this.props.shouldDisplay} shouldDisplay={this.props.shouldDisplay}

View File

@ -170,8 +170,7 @@ export interface FarmbotOsProps {
} }
export interface FarmbotOsState { export interface FarmbotOsState {
osReleaseNotesHeading: string; allOsReleaseNotes: string;
osReleaseNotes: string;
} }
export interface McuInputBoxProps { export interface McuInputBoxProps {

View File

@ -7,6 +7,7 @@ import {
import { BotPosition } from "../../devices/interfaces"; import { BotPosition } from "../../devices/interfaces";
import { fakeCropLiveSearchResult } from "../../__test_support__/fake_crop_search_result"; import { fakeCropLiveSearchResult } from "../../__test_support__/fake_crop_search_result";
import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; import { fakeDesignerState } from "../../__test_support__/fake_designer_state";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
describe("designer reducer", () => { describe("designer reducer", () => {
const oldState = fakeDesignerState; const oldState = fakeDesignerState;
@ -112,4 +113,14 @@ describe("designer reducer", () => {
const newState = designer(state, action); const newState = designer(state, action);
expect(newState.cropSearchInProgress).toEqual(false); expect(newState.cropSearchInProgress).toEqual(false);
}); });
it("starts group sort type trial", () => {
const state = oldState();
state.tryGroupSortType = undefined;
const action: ReduxAction<PointGroupSortType | undefined> = {
type: Actions.TRY_SORT_TYPE, payload: "random"
};
const newState = designer(state, action);
expect(newState.tryGroupSortType).toEqual("random");
});
}); });

View File

@ -20,7 +20,7 @@ import { AxisNumberProperty, BotSize, TaggedPlant } from "./map/interfaces";
import { SelectionBoxData } from "./map/background"; import { SelectionBoxData } from "./map/background";
import { GetWebAppConfigValue } from "../config_storage/actions"; import { GetWebAppConfigValue } from "../config_storage/actions";
import { import {
ExecutableType, PlantPointer ExecutableType, PlantPointer, PointGroupSortType
} from "farmbot/dist/resources/api_resources"; } from "farmbot/dist/resources/api_resources";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app"; import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
import { TimeSettings } from "../interfaces"; import { TimeSettings } from "../interfaces";
@ -108,6 +108,7 @@ export interface DesignerState {
chosenLocation: BotPosition; chosenLocation: BotPosition;
currentPoint: CurrentPointPayl | undefined; currentPoint: CurrentPointPayl | undefined;
openedSavedGarden: string | undefined; openedSavedGarden: string | undefined;
tryGroupSortType: PointGroupSortType | "nn" | undefined;
} }
export type TaggedExecutable = TaggedSequence | TaggedRegimen; export type TaggedExecutable = TaggedSequence | TaggedRegimen;

View File

@ -28,6 +28,7 @@ import {
} from "./layers/plants/plant_actions"; } from "./layers/plants/plant_actions";
import { chooseLocation } from "../move_to"; import { chooseLocation } from "../move_to";
import { GroupOrder } from "../point_groups/group_order_visual"; import { GroupOrder } from "../point_groups/group_order_visual";
import { NNPath } from "../point_groups/paths";
export class GardenMap extends export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> { React.Component<GardenMapProps, Partial<GardenMapState>> {
@ -347,6 +348,8 @@ export class GardenMap extends
GroupOrder = () => <GroupOrder GroupOrder = () => <GroupOrder
plants={this.props.plants} plants={this.props.plants}
mapTransformProps={this.mapTransformProps} /> mapTransformProps={this.mapTransformProps} />
NNPath = () => <NNPath plants={this.props.plants}
mapTransformProps={this.mapTransformProps} />
Bugs = () => showBugs() ? <Bugs mapTransformProps={this.mapTransformProps} Bugs = () => showBugs() ? <Bugs mapTransformProps={this.mapTransformProps}
botSize={this.props.botSize} /> : <g /> botSize={this.props.botSize} /> : <g />
@ -370,6 +373,7 @@ export class GardenMap extends
<this.TargetCoordinate /> <this.TargetCoordinate />
<this.DrawnPoint /> <this.DrawnPoint />
<this.GroupOrder /> <this.GroupOrder />
<this.NNPath />
<this.Bugs /> <this.Bugs />
</svg> </svg>
</svg> </svg>

View File

@ -54,6 +54,7 @@ export interface GardenPlantProps {
dispatch: Function; dispatch: Function;
plant: Readonly<TaggedPlant>; plant: Readonly<TaggedPlant>;
selected: boolean; selected: boolean;
editing: boolean;
dragging: boolean; dragging: boolean;
zoomLvl: number; zoomLvl: number;
activeDragXY: BotPosition | undefined; activeDragXY: BotPosition | undefined;

View File

@ -14,6 +14,7 @@ describe("<GardenPlant/>", () => {
mapTransformProps: fakeMapTransformProps(), mapTransformProps: fakeMapTransformProps(),
plant: fakePlant(), plant: fakePlant(),
selected: false, selected: false,
editing: false,
multiselected: false, multiselected: false,
dragging: false, dragging: false,
dispatch: jest.fn(), dispatch: jest.fn(),

View File

@ -44,7 +44,7 @@ export class GardenPlant extends
render() { render() {
const { selected, dragging, plant, multiselected, mapTransformProps, const { selected, dragging, plant, multiselected, mapTransformProps,
activeDragXY, zoomLvl, animate } = this.props; activeDragXY, zoomLvl, animate, editing } = this.props;
const { id, radius, x, y } = plant.body; const { id, radius, x, y } = plant.body;
const { icon } = this.state; const { icon } = this.state;
@ -65,7 +65,7 @@ export class GardenPlant extends
fill={Color.soilCloud} fill={Color.soilCloud}
fillOpacity={0} />} fillOpacity={0} />}
{multiselected && {multiselected && !editing &&
<g id="selected-plant-indicator"> <g id="selected-plant-indicator">
<Circle <Circle
className={`plant-indicator ${animate ? "animate" : ""}`} className={`plant-indicator ${animate ? "animate" : ""}`}

View File

@ -32,6 +32,7 @@ export function PlantLayer(props: PlantLayerProps) {
mapTransformProps={mapTransformProps} mapTransformProps={mapTransformProps}
plant={p} plant={p}
selected={selected} selected={selected}
editing={editing}
multiselected={multiselected} multiselected={multiselected}
dragging={selected && dragging && editing} dragging={selected && dragging && editing}
dispatch={dispatch} dispatch={dispatch}

View File

@ -8,6 +8,13 @@ jest.mock("../../actions", () => ({
toggleHoveredPlant: jest.fn() toggleHoveredPlant: jest.fn()
})); }));
let mockDev = false;
jest.mock("../../../account/dev/dev_support", () => ({
DevSettings: {
futureFeaturesEnabled: () => mockDev,
}
}));
import React from "react"; import React from "react";
import { GroupDetailActive } from "../group_detail_active"; import { GroupDetailActive } from "../group_detail_active";
import { mount, shallow } from "enzyme"; import { mount, shallow } from "enzyme";
@ -81,4 +88,19 @@ describe("<GroupDetailActive/>", () => {
el.componentWillUnmount && el.componentWillUnmount(); el.componentWillUnmount && el.componentWillUnmount();
expect(clearInterval).toHaveBeenCalledWith(123); expect(clearInterval).toHaveBeenCalledWith(123);
}); });
it("shows paths", () => {
mockDev = true;
const p = fakeProps();
p.plants = [fakePlant(), fakePlant()];
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text().toLowerCase()).toContain("optimized");
});
it("doesn't show paths", () => {
mockDev = false;
const p = fakeProps();
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("optimized");
});
}); });

View File

@ -0,0 +1,94 @@
jest.mock("../../../api/crud", () => ({ edit: jest.fn() }));
import * as React from "react";
import { mount, shallow } from "enzyme";
import { PathInfoBar, nn, NNPath, PathInfoBarProps } from "../paths";
import {
fakePlant, fakePointGroup
} from "../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps
} from "../../../__test_support__/map_transform_props";
import { Actions } from "../../../constants";
import { edit } from "../../../api/crud";
import { error } from "../../../toast/toast";
describe("<PathInfoBar />", () => {
const fakeProps = (): PathInfoBarProps => ({
sortTypeKey: "random",
dispatch: jest.fn(),
group: fakePointGroup(),
pathData: { random: 123 },
});
it("hovers path", () => {
const p = fakeProps();
const wrapper = shallow(<PathInfoBar {...p} />);
wrapper.simulate("mouseEnter");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TRY_SORT_TYPE, payload: "random"
});
});
it("unhovers path", () => {
const p = fakeProps();
const wrapper = shallow(<PathInfoBar {...p} />);
wrapper.simulate("mouseLeave");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TRY_SORT_TYPE, payload: undefined
});
});
it("selects path", () => {
const p = fakeProps();
const wrapper = shallow(<PathInfoBar {...p} />);
wrapper.simulate("click");
expect(edit).toHaveBeenCalledWith(p.group, { sort_type: "random" });
});
it("selects new path", () => {
const p = fakeProps();
p.sortTypeKey = "nn";
const wrapper = shallow(<PathInfoBar {...p} />);
wrapper.simulate("click");
expect(edit).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith("Not supported yet.");
});
});
describe("nearest neighbor algorithm", () => {
it("returns optimized array", () => {
const p1 = fakePlant();
p1.body.x = 100;
p1.body.y = 100;
const p2 = fakePlant();
p2.body.x = 200;
p2.body.y = 200;
const p3 = fakePlant();
p3.body.x = 175;
p3.body.y = 1000;
const p4 = fakePlant();
p4.body.x = 1000;
p4.body.y = 150;
const points = nn([p4, p2, p3, p1]);
expect(points).toEqual([p1, p2, p3, p4]);
});
});
describe("<NNPath />", () => {
const fakeProps = () => ({
plants: [],
mapTransformProps: fakeMapTransformProps(),
});
it("doesn't render optimized path", () => {
const wrapper = mount(<NNPath {...fakeProps()} />);
expect(wrapper.html()).toEqual("<g></g>");
});
it("renders optimized path", () => {
localStorage.setItem("try_it", "ok");
const wrapper = mount(<NNPath {...fakeProps()} />);
expect(wrapper.html()).not.toEqual("<g></g>");
});
});

View File

@ -13,6 +13,8 @@ import { TaggedPlant } from "../map/interfaces";
import { PointGroupSortSelector, sortGroupBy } from "./point_group_sort_selector"; import { PointGroupSortSelector, sortGroupBy } from "./point_group_sort_selector";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { PointGroupItem } from "./point_group_item"; import { PointGroupItem } from "./point_group_item";
import { Paths } from "./paths";
import { DevSettings } from "../../account/dev/dev_support";
interface GroupDetailActiveProps { interface GroupDetailActiveProps {
dispatch: Function; dispatch: Function;
@ -97,6 +99,11 @@ export class GroupDetailActive
<div className="groups-list-wrapper"> <div className="groups-list-wrapper">
{this.icons} {this.icons}
</div> </div>
{DevSettings.futureFeaturesEnabled() &&
<Paths
points={this.props.plants}
dispatch={this.props.dispatch}
group={this.props.group} />}
<DeleteButton <DeleteButton
className="groups-delete-btn" className="groups-delete-btn"
dispatch={this.props.dispatch} dispatch={this.props.dispatch}

View File

@ -6,6 +6,7 @@ import { isUndefined } from "lodash";
import { sortGroupBy } from "./point_group_sort_selector"; import { sortGroupBy } from "./point_group_sort_selector";
import { Color } from "../../ui"; import { Color } from "../../ui";
import { transformXY } from "../map/util"; import { transformXY } from "../map/util";
import { nn } from "./paths";
export interface GroupOrderProps { export interface GroupOrderProps {
plants: TaggedPlant[]; plants: TaggedPlant[];
@ -14,23 +15,41 @@ export interface GroupOrderProps {
const sortedPointCoordinates = const sortedPointCoordinates =
(plants: TaggedPlant[]): { x: number, y: number }[] => { (plants: TaggedPlant[]): { x: number, y: number }[] => {
const group = fetchGroupFromUrl(store.getState().resources.index); const { resources } = store.getState();
const group = fetchGroupFromUrl(resources.index);
if (isUndefined(group)) { return []; } if (isUndefined(group)) { return []; }
const groupPlants = plants const groupPlants = plants
.filter(p => group.body.point_ids.includes(p.body.id || 0)); .filter(p => group.body.point_ids.includes(p.body.id || 0));
return sortGroupBy(group.body.sort_type, groupPlants) const groupSortType = resources.consumers.farm_designer.tryGroupSortType
.map(p => ({ x: p.body.x, y: p.body.y })); || group.body.sort_type;
const sorted = groupSortType == "nn"
? nn(groupPlants)
: sortGroupBy(groupSortType, groupPlants);
return sorted.map(p => ({ x: p.body.x, y: p.body.y }));
}; };
export const GroupOrder = (props: GroupOrderProps) => { export interface PointsPathLineProps {
const points = sortedPointCoordinates(props.plants); orderedPoints: { x: number, y: number }[];
return <g id="group-order" mapTransformProps: MapTransformProps;
stroke={Color.mediumGray} strokeWidth={3} strokeDasharray={12}> color?: Color;
{points.map((p, i) => { dash?: number;
const prev = i > 0 ? points[i - 1] : p; strokeWidth?: number;
}
export const PointsPathLine = (props: PointsPathLineProps) =>
<g id="group-order"
stroke={props.color || Color.mediumGray}
strokeWidth={props.strokeWidth || 3}
strokeDasharray={props.dash || 12}>
{props.orderedPoints.map((p, i) => {
const prev = i > 0 ? props.orderedPoints[i - 1] : p;
const one = transformXY(prev.x, prev.y, props.mapTransformProps); const one = transformXY(prev.x, prev.y, props.mapTransformProps);
const two = transformXY(p.x, p.y, props.mapTransformProps); const two = transformXY(p.x, p.y, props.mapTransformProps);
return <line key={i} x1={one.qx} y1={one.qy} x2={two.qx} y2={two.qy} />; return <line key={i} x1={one.qx} y1={one.qy} x2={two.qx} y2={two.qy} />;
})} })}
</g>; </g>;
};
export const GroupOrder = (props: GroupOrderProps) =>
<PointsPathLine
orderedPoints={sortedPointCoordinates(props.plants)}
mapTransformProps={props.mapTransformProps} />;

View File

@ -0,0 +1,129 @@
import * as React from "react";
import { TaggedPlant, MapTransformProps } from "../map/interfaces";
import { sortGroupBy, sortOptionsTable } from "./point_group_sort_selector";
import { sortBy } from "lodash";
import { PointsPathLine } from "./group_order_visual";
import { Color } from "../../ui";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { Actions } from "../../constants";
import { edit } from "../../api/crud";
import { TaggedPointGroup } from "farmbot";
import { error } from "../../toast/toast";
const xy = (point: TaggedPlant) => ({ x: point.body.x, y: point.body.y });
const distance = (p1: { x: number, y: number }, p2: { x: number, y: number }) =>
Math.pow(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2), 0.5);
const pathDistance = (points: TaggedPlant[]) => {
let total = 0;
let prev: { x: number, y: number } | undefined = undefined;
points.map(xy)
.map(p => {
prev ? total += distance(p, prev) : 0;
prev = p;
});
return Math.round(total);
};
const findNearest =
(from: { x: number, y: number }, available: TaggedPlant[]) => {
const distances = available.map(p => ({
point: p, distance: distance(xy(p), from)
}));
return sortBy(distances, "distance")[0].point;
};
export const nn = (points: TaggedPlant[]) => {
let available = points.slice(0);
const ordered: TaggedPlant[] = [];
let from = { x: 0, y: 0 };
points.map(() => {
const nearest = findNearest(from, available);
ordered.push(nearest);
from = { x: nearest.body.x, y: nearest.body.y };
available = available.filter(p => p.uuid !== nearest.uuid);
});
return ordered;
};
const SORT_TYPES: (PointGroupSortType | "nn")[] = [
"random", "xy_ascending", "xy_descending", "yx_ascending", "yx_descending"];
export interface PathInfoBarProps {
sortTypeKey: PointGroupSortType | "nn";
dispatch: Function;
group: TaggedPointGroup;
pathData: { [key: string]: number };
}
export const PathInfoBar = (props: PathInfoBarProps) => {
const { sortTypeKey, dispatch, group } = props;
const pathLength = props.pathData[sortTypeKey];
const maxLength = Math.max(...Object.values(props.pathData));
const normalizedLength = pathLength / maxLength * 100;
const sortLabel =
sortTypeKey == "nn" ? "Optimized" : sortOptionsTable()[sortTypeKey];
return <div className={"sort-path-info-bar"}
onMouseEnter={() =>
dispatch({ type: Actions.TRY_SORT_TYPE, payload: sortTypeKey })}
onMouseLeave={() =>
dispatch({ type: Actions.TRY_SORT_TYPE, payload: undefined })}
onClick={() =>
sortTypeKey == "nn"
? error(t("Not supported yet."))
: dispatch(edit(group, { sort_type: sortTypeKey }))}
style={{ width: `${normalizedLength}%` }}>
{`${sortLabel}: ${Math.round(pathLength / 10) / 100}m`}
</div>;
};
interface PathsProps {
points: TaggedPlant[];
dispatch: Function;
group: TaggedPointGroup;
}
interface PathsState {
pathData: { [key: string]: number };
}
export class Paths extends React.Component<PathsProps, PathsState> {
state: PathsState = { pathData: {} };
generatePathData = (points: TaggedPlant[]) => {
SORT_TYPES.map((sortType: PointGroupSortType) =>
this.state.pathData[sortType] =
pathDistance(sortGroupBy(sortType, points)));
this.state.pathData.nn = pathDistance(nn(points));
};
render() {
if (!this.state.pathData.nn) { this.generatePathData(this.props.points); }
return <div>
<label>{t("Path lengths by sort type")}</label>
{SORT_TYPES.concat("nn").map(st =>
<PathInfoBar key={st}
sortTypeKey={st}
dispatch={this.props.dispatch}
group={this.props.group}
pathData={this.state.pathData} />)}
</div>;
}
}
interface NNPathProps {
plants: TaggedPlant[];
mapTransformProps: MapTransformProps;
}
export const NNPath = (props: NNPathProps) =>
localStorage.getItem("try_it") == "ok"
? <PointsPathLine
color={Color.blue}
strokeWidth={2}
dash={1}
orderedPoints={nn(props.plants).map(xy)}
mapTransformProps={props.mapTransformProps} />
: <g />;

View File

@ -11,7 +11,7 @@ interface Props {
value: PointGroupSortType; value: PointGroupSortType;
} }
const optionsTable = (): Record<PointGroupSortType, string> => ({ export const sortOptionsTable = (): Record<PointGroupSortType, string> => ({
"random": t("Random Order"), "random": t("Random Order"),
"xy_ascending": t("X/Y, Ascending"), "xy_ascending": t("X/Y, Ascending"),
"xy_descending": t("X/Y, Descending"), "xy_descending": t("X/Y, Descending"),
@ -21,7 +21,7 @@ const optionsTable = (): Record<PointGroupSortType, string> => ({
const optionPlusDescriptions = () => const optionPlusDescriptions = () =>
(Object (Object
.entries(optionsTable()) as [PointGroupSortType, string][]) .entries(sortOptionsTable()) as [PointGroupSortType, string][])
.map(x => ({ label: x[1], value: x[0] })); .map(x => ({ label: x[1], value: x[0] }));
const optionList = const optionList =
@ -32,7 +32,7 @@ export const isSortType = (x: unknown): x is PointGroupSortType => {
}; };
const selected = (value: PointGroupSortType) => ({ const selected = (value: PointGroupSortType) => ({
label: t(optionsTable()[value] || value), label: t(sortOptionsTable()[value] || value),
value: value value: value
}); });
@ -50,6 +50,7 @@ export function PointGroupSortSelector(p: Props) {
</label> </label>
</div> </div>
<FBSelect <FBSelect
key={p.value}
list={optionPlusDescriptions()} list={optionPlusDescriptions()}
selectedItem={selected(p.value as PointGroupSortType)} selectedItem={selected(p.value as PointGroupSortType)}
onChange={sortTypeChange(p.onChange)} /> onChange={sortTypeChange(p.onChange)} />

View File

@ -5,6 +5,7 @@ import { cloneDeep } from "lodash";
import { TaggedResource } from "farmbot"; import { TaggedResource } from "farmbot";
import { Actions } from "../constants"; import { Actions } from "../constants";
import { BotPosition } from "../devices/interfaces"; import { BotPosition } from "../devices/interfaces";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
export let initialState: DesignerState = { export let initialState: DesignerState = {
selectedPlants: undefined, selectedPlants: undefined,
@ -19,6 +20,7 @@ export let initialState: DesignerState = {
chosenLocation: { x: undefined, y: undefined, z: undefined }, chosenLocation: { x: undefined, y: undefined, z: undefined },
currentPoint: undefined, currentPoint: undefined,
openedSavedGarden: undefined, openedSavedGarden: undefined,
tryGroupSortType: undefined,
}; };
export let designer = generateReducer<DesignerState>(initialState) export let designer = generateReducer<DesignerState>(initialState)
@ -69,4 +71,8 @@ export let designer = generateReducer<DesignerState>(initialState)
.add<string | undefined>(Actions.CHOOSE_SAVED_GARDEN, (s, { payload }) => { .add<string | undefined>(Actions.CHOOSE_SAVED_GARDEN, (s, { payload }) => {
s.openedSavedGarden = payload; s.openedSavedGarden = payload;
return s; return s;
})
.add<PointGroupSortType | undefined>(Actions.TRY_SORT_TYPE, (s, { payload }) => {
s.tryGroupSortType = payload;
return s;
}); });

View File

@ -9,7 +9,7 @@ export function findBySlug(
return crop || { return crop || {
crop: { crop: {
name: startCase((slug || t("Name")).split("-").join(" ")), name: startCase((slug || t("Name")).split("-").join(" ")),
slug: "slug", slug: slug || "slug",
binomial_name: t("Binomial Name"), binomial_name: t("Binomial Name"),
common_names: [t("Common Names")], common_names: [t("Common Names")],
description: t("Description"), description: t("Description"),

View File

@ -13,20 +13,24 @@ const mockResponse: { promise: Promise<{}> } = {
}; };
jest.mock("axios", () => ({ jest.mock("axios", () => ({
get: () => mockResponse.promise get: jest.fn(() => mockResponse.promise)
})); }));
jest.unmock("../cached_crop"); jest.unmock("../cached_crop");
import { cachedCrop } from "../cached_crop"; import { cachedCrop } from "../cached_crop";
import axios from "axios";
import { times } from "lodash";
describe("cachedIcon()", () => { describe("cachedIcon()", () => {
it("does an HTTP request if the icon can't be found locally", async () => { it("does an HTTP request if the icon can't be found locally", async () => {
times(10, () => cachedCrop("lettuce"));
const item1 = await cachedCrop("lettuce"); const item1 = await cachedCrop("lettuce");
expect(item1.svg_icon).toContain("<svg>Wow</svg>"); expect(item1.svg_icon).toContain("<svg>Wow</svg>");
const item2 = await cachedCrop("lettuce"); const item2 = await cachedCrop("lettuce");
expect(item2.slug).toBe(item1.slug); expect(item2.slug).toBe(item1.slug);
expect(item2.svg_icon).toBe(item1.svg_icon); expect(item2.svg_icon).toBe(item1.svg_icon);
expect(item2.spread).toBe(undefined); expect(item2.spread).toBe(undefined);
expect(axios.get).toHaveBeenCalledTimes(1);
}); });
it("handles unexpected responses from OpenFarm", async () => { it("handles unexpected responses from OpenFarm", async () => {

View File

@ -37,7 +37,8 @@ function localStorageIconSet(icon: OFIcon): void {
* and the garlic icon is not cached locally, and you try to render 10 garlic * and the garlic icon is not cached locally, and you try to render 10 garlic
* icons in the first 100ms, and HTTP requests take more than 100ms, you will * icons in the first 100ms, and HTTP requests take more than 100ms, you will
* end up performing 10 HTTP requests at application start time. Not very * end up performing 10 HTTP requests at application start time. Not very
* efficient */ * efficient.
* SOLUTION: Keep a record of open requests to avoid duplicate requests. */
const promiseCache: Dictionary<Promise<Readonly<OFCropAttrs>>> = {}; const promiseCache: Dictionary<Promise<Readonly<OFCropAttrs>>> = {};
const cacheTheIcon = (slug: string) => const cacheTheIcon = (slug: string) =>
@ -60,6 +61,8 @@ const cacheTheIcon = (slug: string) =>
function HTTPIconFetch(slug: string) { function HTTPIconFetch(slug: string) {
const url = OpenFarmAPI.OFBaseURL + slug; const url = OpenFarmAPI.OFBaseURL + slug;
// Avoid duplicate requests.
if (promiseCache[url]) { return promiseCache[url]; }
promiseCache[url] = axios promiseCache[url] = axios
.get<OFCropResponse>(url) .get<OFCropResponse>(url)
.then(cacheTheIcon(slug), cacheTheIcon(slug)); .then(cacheTheIcon(slug), cacheTheIcon(slug));

View File

@ -8,7 +8,13 @@ jest.mock("../set_active_sequence_by_name", () => ({
})); }));
jest.mock("../../api/crud", () => ({ jest.mock("../../api/crud", () => ({
init: jest.fn() init: jest.fn(),
destroy: jest.fn(),
}));
let mockDev = false;
jest.mock("../../account/dev/dev_support", () => ({
DevSettings: { quickDeleteEnabled: () => mockDev, }
})); }));
import * as React from "react"; import * as React from "react";
@ -17,7 +23,7 @@ import { SequencesList } from "../sequences_list";
import { fakeSequence } from "../../__test_support__/fake_state/resources"; import { fakeSequence } from "../../__test_support__/fake_state/resources";
import { SequencesListProps } from "../interfaces"; import { SequencesListProps } from "../interfaces";
import { Actions } from "../../constants"; import { Actions } from "../../constants";
import { init } from "../../api/crud"; import { init, destroy } from "../../api/crud";
import { push } from "../../history"; import { push } from "../../history";
import { resourceUsageList } from "../../resources/in_use"; import { resourceUsageList } from "../../resources/in_use";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
@ -25,6 +31,7 @@ import { resourceReducer } from "../../resources/reducer";
import { resourceReady } from "../../sync/actions"; import { resourceReady } from "../../sync/actions";
import { setActiveSequenceByName } from "../set_active_sequence_by_name"; import { setActiveSequenceByName } from "../set_active_sequence_by_name";
import { inputEvent } from "../../__test_support__/fake_input_event"; import { inputEvent } from "../../__test_support__/fake_input_event";
import { Link } from "../../link";
describe("<SequencesList />", () => { describe("<SequencesList />", () => {
const fakeSequences = () => { const fakeSequences = () => {
@ -113,8 +120,28 @@ describe("<SequencesList />", () => {
it("opens sequence", () => { it("opens sequence", () => {
const p = fakeProps(); const p = fakeProps();
const wrapper = shallow(<SequencesList {...p} />); const wrapper = mount(<SequencesList {...p} />);
wrapper.find("Link").first().simulate("click"); wrapper.find(Link).first().simulate("click");
expect(setActiveSequenceByName).toHaveBeenCalled(); expect(setActiveSequenceByName).toHaveBeenCalled();
}); });
it("doesn't delete sequence", () => {
mockDev = false;
const p = fakeProps([fakeSequence()]);
const wrapper = mount(<SequencesList {...p} />);
const button = wrapper.find("button").last();
expect(button.hasClass("quick-del")).toBeFalsy();
button.simulate("click");
expect(destroy).not.toHaveBeenCalled();
});
it("deletes sequence", () => {
mockDev = true;
const p = fakeProps([fakeSequence()]);
const wrapper = mount(<SequencesList {...p} />);
const button = wrapper.find("button").last();
expect(button.hasClass("quick-del")).toBeTruthy();
button.simulate("click");
expect(destroy).toHaveBeenCalledWith(p.sequences[0].uuid);
});
}); });

View File

@ -4,7 +4,7 @@ import { SequencesListProps, SequencesListState } from "./interfaces";
import { sortResourcesById, urlFriendly, lastUrlChunk } from "../util"; import { sortResourcesById, urlFriendly, lastUrlChunk } from "../util";
import { Row, Col } from "../ui/index"; import { Row, Col } from "../ui/index";
import { TaggedSequence } from "farmbot"; import { TaggedSequence } from "farmbot";
import { init } from "../api/crud"; import { init, destroy } from "../api/crud";
import { Content } from "../constants"; import { Content } from "../constants";
import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger"; import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger";
import { Link } from "../link"; import { Link } from "../link";
@ -13,48 +13,79 @@ import { UUID, VariableNameSet } from "../resources/interfaces";
import { variableList } from "./locals_list/variable_support"; import { variableList } from "./locals_list/variable_support";
import { t } from "../i18next_wrapper"; import { t } from "../i18next_wrapper";
import { EmptyStateWrapper, EmptyStateGraphic } from "../ui/empty_state_wrapper"; import { EmptyStateWrapper, EmptyStateGraphic } from "../ui/empty_state_wrapper";
import { DevSettings } from "../account/dev/dev_support";
const filterFn = (searchTerm: string) => (seq: TaggedSequence): boolean => seq const filterFn = (searchTerm: string) => (seq: TaggedSequence): boolean =>
.body seq.body.name.toLowerCase().includes(searchTerm);
.name
.toLowerCase()
.includes(searchTerm);
const sequenceList = (props: { interface SequenceButtonWrapperProps {
dispatch: Function, ts: TaggedSequence;
resourceUsage: Record<UUID, boolean | undefined>, dispatch: Function;
sequenceMetas: Record<UUID, VariableNameSet | undefined> variableData: VariableNameSet | undefined;
}) => children: React.ReactChild;
}
/** Sequence list item wrapper for drag action and link to sequence. */
const SequenceButtonWrapper = (props: SequenceButtonWrapperProps) =>
<div className="sequence-list-item" key={props.ts.uuid}>
<StepDragger
dispatch={props.dispatch}
step={{
kind: "execute",
args: { sequence_id: props.ts.body.id || 0 },
body: variableList(props.variableData)
}}
intent="step_splice"
draggerId={NULL_DRAGGER_ID}>
<Link
to={`/app/sequences/${urlFriendly(props.ts.body.name) || ""}`}
key={props.ts.uuid}
onClick={setActiveSequenceByName}>
{props.children}
</Link>
</StepDragger>
</div>;
interface SequenceButtonProps {
ts: TaggedSequence;
inUse: boolean;
deleteFunc?: () => void;
}
/** Sequence list item label and indicators. */
const SequenceButton = (props: SequenceButtonProps) => {
const { color, name } = props.ts.body;
const css = [`fb-button`, `block`, `full-width`, `${color || "purple"}`];
lastUrlChunk() === urlFriendly(name) && css.push("active");
props.deleteFunc && css.push("quick-del");
const nameWithSaveIndicator = name + (props.ts.specialStatus ? "*" : "");
return <button className={css.join(" ")} draggable={true}
onClick={props.deleteFunc}>
<label>{nameWithSaveIndicator}</label>
{props.inUse &&
<i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}
</button>;
};
interface SequenceListItemProps {
dispatch: Function;
resourceUsage: Record<UUID, boolean | undefined>;
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
}
const SequenceListItem = (props: SequenceListItemProps) =>
(ts: TaggedSequence) => { (ts: TaggedSequence) => {
const css = const inUse = !!props.resourceUsage[ts.uuid];
[`fb-button`, `block`, `full-width`, `${ts.body.color || "purple"}`]; const variableData = props.sequenceMetas[ts.uuid];
lastUrlChunk() === urlFriendly(ts.body.name) && css.push("active"); const deleteSeq = () => props.dispatch(destroy(ts.uuid));
const { uuid } = ts;
const nameWithSaveIndicator = ts.body.name + (ts.specialStatus ? "*" : "");
const inUse = !!props.resourceUsage[uuid];
const variableData = props.sequenceMetas[uuid];
return <div className="sequence-list-items" key={uuid}> return <div className="sequence-list-item" key={ts.uuid}>
<StepDragger {DevSettings.quickDeleteEnabled()
dispatch={props.dispatch} ? <SequenceButton ts={ts} inUse={inUse} deleteFunc={deleteSeq} />
step={{ : <SequenceButtonWrapper
kind: "execute", ts={ts} dispatch={props.dispatch} variableData={variableData}>
args: { sequence_id: ts.body.id || 0 }, <SequenceButton ts={ts} inUse={inUse} />
body: variableList(variableData) </SequenceButtonWrapper>}
}}
intent="step_splice"
draggerId={NULL_DRAGGER_ID}>
<Link
to={`/app/sequences/${urlFriendly(ts.body.name) || ""}`}
key={uuid}
onClick={setActiveSequenceByName}>
<button className={css.join(" ")} draggable={true}>
<label>{nameWithSaveIndicator}</label>
{inUse &&
<i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}
</button>
</Link>
</StepDragger>
</div>; </div>;
}; };
@ -126,7 +157,9 @@ export class SequencesList extends
<div className="sequence-list"> <div className="sequence-list">
{sortResourcesById(sequences) {sortResourcesById(sequences)
.filter(filterFn(searchTerm)) .filter(filterFn(searchTerm))
.map(sequenceList({ dispatch, resourceUsage, sequenceMetas }))} .map(SequenceListItem({
dispatch, resourceUsage, sequenceMetas
}))}
</div>} </div>}
</EmptyStateWrapper> </EmptyStateWrapper>
</Col> </Col>

View File

@ -141,7 +141,7 @@ namespace :api do
puts "Setting new support target to #{data.to_s}" puts "Setting new support target to #{data.to_s}"
GlobalConfig # Set the new oldest support version. GlobalConfig # Set the new oldest support version.
.find_by(key: "FBOS_END_OF_LIFE_VERSION") .find_by(key: "FBOS_END_OF_LIFE_VERSION")
.update_attributes!(value: data.to_s) .update!(value: data.to_s)
end end
end end
end end

View File

@ -14,7 +14,7 @@ FRACTION_DELIM = "/"
def open_json(url) def open_json(url)
begin begin
JSON.parse(open(url).read) JSON.parse(open(url).read)
rescue OpenURI::HTTPError => exception rescue *[OpenURI::HTTPError, SocketError] => exception
puts exception.message puts exception.message
return {} return {}
end end
@ -80,7 +80,7 @@ end
# Fetch a page of build coverage report results. # Fetch a page of build coverage report results.
def fetch_builds_for_page(page_number) def fetch_builds_for_page(page_number)
open_json("#{LATEST_COV_URL}?page=#{page_number}")["builds"] open_json("#{LATEST_COV_URL}?page=#{page_number}")["builds"] || []
end end
# Number of coverage build data pages required to fetch the desired build count. # Number of coverage build data pages required to fetch the desired build count.

View File

@ -86,7 +86,7 @@ describe Api::FbosConfigsController do
it 'resets everything to the defaults' do it 'resets everything to the defaults' do
sign_in user sign_in user
old_conf = device.fbos_config old_conf = device.fbos_config
old_conf.update_attributes(arduino_debug_messages: 23) old_conf.update(arduino_debug_messages: 23)
delete :destroy, params: {} delete :destroy, params: {}
expect(response.status).to eq(200) expect(response.status).to eq(200)
new_conf = device.reload.fbos_config new_conf = device.reload.fbos_config

View File

@ -138,7 +138,7 @@ describe Api::FirmwareConfigsController do
it 'resets everything to the defaults' do it 'resets everything to the defaults' do
sign_in user sign_in user
old_conf = device.firmware_config old_conf = device.firmware_config
old_conf.update_attributes(pin_guard_5_pin_nr: 23) old_conf.update(pin_guard_5_pin_nr: 23)
delete :destroy, params: {} delete :destroy, params: {}
expect(response.status).to eq(200) expect(response.status).to eq(200)
new_conf = device.reload.firmware_config new_conf = device.reload.firmware_config

Some files were not shown because too many files have changed in this diff Show More