Merge branch 'staging' into master

pull/902/head
Rick Carlino 2018-07-01 20:10:50 -05:00 committed by GitHub
commit 88e830cbd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
207 changed files with 3653 additions and 2305 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ erd_diagram.png
latest_corpus.ts
log/*.log
mqtt/rabbitmq.config
mqtt/rabbitmq.conf
node_modules
node_modules/
package-lock.json

View File

@ -1,6 +1,6 @@
language: node_js
node_js:
- 8.9.4
- 8.11.3
cache:
yarn: true
directories:
@ -8,6 +8,7 @@ cache:
- /home/travis/bundle
env:
global:
- ADMIN_PASSWORD=not_a_real_password
- SECRET_TOKEN=e815982094c62436066bafc9151f2d33c4a351a776654cb7487476de260a4592
- OS_UPDATE_SERVER=http://example.com
- FW_UPDATE_SERVER=http://example.com

View File

@ -8,7 +8,7 @@ gem "delayed_job"
gem "devise"
gem "discard"
gem "figaro"
gem "fog-google", git: "https://github.com/fog/fog-google"
gem "fog-google", "1.6.0"
gem "font-awesome-rails"
gem "foreman"
gem "jwt"

View File

@ -4,16 +4,6 @@ GIT
specs:
smarf_doc (1.0.0)
GIT
remote: https://github.com/fog/fog-google
revision: a2e46a2dafb01e896713ae419e5643ae6c2549a2
specs:
fog-google (1.3.3)
fog-core
fog-json
fog-xml
google-api-client (~> 0.19.1)
GEM
remote: https://rubygems.org/
specs:
@ -69,9 +59,9 @@ GEM
arel (9.0.0)
bcrypt (3.1.12)
builder (3.2.3)
bunny (2.10.0)
bunny (2.11.0)
amq-protocol (~> 2.3.0)
capybara (3.2.1)
capybara (3.3.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -130,20 +120,25 @@ GEM
excon (~> 0.58)
formatador (~> 0.2)
mime-types
fog-json (1.1.0)
fog-core (~> 2.0)
fog-google (1.6.0)
fog-core
fog-json
fog-xml
google-api-client (~> 0.23.0)
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-rails (4.7.0.4)
railties (>= 3.2, < 6.0)
foreman (0.84.0)
foreman (0.85.0)
thor (~> 0.19.1)
formatador (0.2.5)
globalid (0.4.1)
activesupport (>= 4.2.0)
google-api-client (0.19.8)
google-api-client (0.23.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.5, < 0.7.0)
httpclient (>= 2.8.1, < 3.0)
@ -198,7 +193,7 @@ GEM
mutations (0.8.2)
activesupport
nio4r (2.3.1)
nokogiri (1.8.2)
nokogiri (1.8.3)
mini_portile2 (~> 2.3.0)
orm_adapter (0.5.0)
os (0.9.6)
@ -208,7 +203,7 @@ GEM
mime-types
mimemagic (~> 0.3.0)
terrapin (~> 0.6.0)
passenger (5.3.1)
passenger (5.3.3)
rack
rake (>= 0.8.1)
pg (1.0.0)
@ -221,7 +216,7 @@ GEM
pry (>= 0.10.4)
public_suffix (3.0.2)
rack (2.0.5)
rack-attack (5.2.0)
rack-attack (5.3.2)
rack
rack-cors (1.0.2)
rack-test (1.0.0)
@ -270,7 +265,7 @@ GEM
responders (2.4.0)
actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3)
retriable (3.1.1)
retriable (3.1.2)
rollbar (2.16.2)
multi_json
rspec (3.7.0)
@ -300,7 +295,7 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
secure_headers (6.0.0)
selenium-webdriver (3.12.0)
selenium-webdriver (3.13.0)
childprocess (~> 0.5)
rubyzip (~> 1.2)
signet (0.8.1)
@ -365,7 +360,7 @@ DEPENDENCIES
factory_bot_rails
faker
figaro
fog-google!
fog-google (= 1.6.0)
font-awesome-rails
foreman
hashdiff

View File

@ -1,7 +1,8 @@
# Run Rails & Webpack concurrently
rails: rails s -e development -p ${API_PORT:-3000} -b 0.0.0.0
webpack: ./node_modules/.bin/webpack-dev-server --config config/webpack.config.js
worker: rake jobs:work
worker1: rake jobs:work
worker2: rake jobs:work
logger: rails r lib/log_service.rb
# UNCOMMENT THIS LINE IF YOU ARE DOING MOBILE TESTING:

View File

@ -11,6 +11,10 @@ This repository is intended for *software developers* who wish to modify the [Fa
If you are a developer interested in contributing or would like to provision your own server, you are in the right place.
We do not have the resources available to help novice developers learn to setup servers, environments, configurations, or perform basic Linux command line instructions.
If you raise an issue indicating that you haven't followed the setup instructions, looked through past issues, or done a cursory internet search for basic help, expect the issue to be closed and we'll point you to the setup instructions. *Again, if you do not have at least intermediate Linux and Ruby experience, please use the hosted version of the web app at my.farm.bot.*
# Q: Where do I report security issues?
We take security seriously and value the input of independent researchers. Please see our [responsible disclosure guidelines](https://farm.bot/responsible-disclosure-of-security-vulnerabilities/).
@ -25,17 +29,11 @@ For a list of example API requests and responses, see our [reference documentati
# Q: How do I Setup an instance locally?
## Prerequisites
We provide example setup instructions for Ubuntu 18 [here](https://github.com/FarmBot/Farmbot-Web-App/blob/master/ubuntu_example.sh).
Installation requires an x86 desktop machine running a fresh installation of Ubuntu 18.
Installation was last tested against Ubuntu 18.04 in June of 2018 on an x86 based machine.
We **do not recomend running the server on a Raspberry Pi** due to issues with ARM compilation and memory usage. **Windows is not supported** at this time.
## Setup
A step-by-step setup guide for Ubuntu 18 can be found [here](https://github.com/FarmBot/Farmbot-Web-App/blob/master/ubuntu_example.sh). Installation on distributions other than Ubuntu is possible, but we do not provide installation support.
Installation was last tested against Ubuntu 18.04 in June of 2018. Please [Raise an issue](https://github.com/FarmBot/Farmbot-Web-App/issues/new?title=Installation%20Failure) if you hit problems with any of these steps. *We can't fix issues we don't know about.*
Our ability to help individual users with private setup is limited. Using the public server at http://my.farm.bot is the recommended setup for end users. Please see the top of this document for more information.
# Config Settings (important)

View File

@ -1,5 +1,5 @@
<% if (response.success? || note.present?) %>
# <%= response.success? ? "" : "(NOT OK)" %> <%= request.pretty_url %>
<% if (response.successful? || note.present?) %>
# <%= response.successful? ? "" : "(NOT OK)" %> <%= request.pretty_url %>
<% if note.present? %>
**Notes:** <%= note %>
<% end %>

View File

@ -101,16 +101,6 @@ private
reset_session
end
def current_device
if @current_device
@current_device
else
@current_device = (current_user.try(:device) || no_device)
Device.current = @current_device # Mutable state eww
@current_device
end
end
def no_device
raise Errors::NoBot
end

View File

@ -0,0 +1,28 @@
module Api
class DiagnosticDumpsController < Api::AbstractController
def index
render json: diagnostic_dumps
end
def create
Rollbar.info("Device #{current_device.id} created a diagnostic")
mutate DiagnosticDumps::Create.run(raw_json, device: current_device)
end
def destroy
diagnostic_dump.destroy!
render json: ""
end
private
def diagnostic_dumps
current_device.diagnostic_dumps
end
def diagnostic_dump
@diagnostic_dump ||= diagnostic_dumps.find(params[:id])
end
end
end

View File

@ -20,7 +20,7 @@ module Api
end
def destroy
image.delay.destroy!
Image.delay.maybe_destroy(image.id) # See notes. This is for edge cases.
render json: ""
end

View File

@ -27,7 +27,9 @@ module Api
end
def your_regimens
Regimen.includes(:farm_events).where(regimen_params)
Regimen
.includes(:farm_events, :regimen_items)
.where(regimen_params)
end
def regimen_params

View File

@ -0,0 +1,112 @@
module Api
# When RabbitMQ gets a connection, it will check in with the API to make sure
# the user is allowed to perform the action.
# Returning "allow" will allow them to perform the requested action.
# Any other response results in denial.
# Results are cached for 10 minutes to prevent too many requests to the API.
class RmqUtilsController < Api::AbstractController
# The only valid format for AMQP / MQTT topics.
# Prevents a whole host of abuse / security issues.
TOPIC_REGEX = \
/(bot\.device_)\d*\.(from_clients|from_device|logs|status|sync)\.?.*/
MALFORMED_TOPIC = "malformed topic. Must match #{TOPIC_REGEX.inspect}"
ALL = [:user, :vhost, :resource, :topic]
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
RESOURCES = ["queue", "exchange"]
PERMISSIONS = ["configure", "read", "write"]
skip_before_action :check_fbos_version, only: ALL
skip_before_action :authenticate_user!, only: ALL
before_action :scrutinize_topic_string
def user
case username
when "guest" then deny
when "admin" then authenticate_admin
else; device_id_in_username == current_device.id ? allow : deny
end
end
def vhost
if is_admin
allow
else
params["vhost"] == VHOST ? allow : deny
end
end
def resource
if is_admin
allow
else
res, perm = [params["resource"], params["permission"]]
ok = RESOURCES.include?(res) && PERMISSIONS.include?(perm)
ok ? allow : deny
end
end
def topic
if is_admin
allow
else
device_id_in_topic == device_id_in_username ? allow : deny
end
end
private
def is_admin
username == "admin"
end
def authenticate_admin
correct_pw = password == ENV.fetch("ADMIN_PASSWORD")
ok = is_admin && correct_pw
ok ? allow : deny
end
def deny
render json: "deny", status: 403
end
def allow
render json: "allow"
end
def username
@username ||= params["username"]
end
def password
@password ||= params["password"]
end
def routing_key
@routing_key ||= params["routing_key"]
end
def scrutinize_topic_string
return if is_admin
is_ok = routing_key ? !!TOPIC_REGEX.match(routing_key) : true
render json: MALFORMED_TOPIC, status: 422 unless is_ok
end
def device_id_in_topic
(routing_key || "") # "bot.device_9.logs"
.gsub("bot.device_", "") # "9.logs"
.split(".") # ["9", "logs"]
.first # "9"
.to_i || 0 # 9
end
def current_device
@current_device ||= Auth::FromJWT.run!(jwt: password).device
rescue Mutations::ValidationException => e
raise JWT::VerificationError, "RMQ Provided bad token"
end
def device_id_in_username
@device_id ||= username.gsub("device_", "").to_i
end
end
end

View File

@ -1,4 +1,18 @@
class ApplicationController < ActionController::Base
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :null_session
def current_device
if @current_device
@current_device
else
@current_device = (current_user.try(:device) || no_device)
Device.current = @current_device # Mutable state eww
@current_device
end
end
def current_device_id
"device_#{current_device.try(:id) || 0}"
end
end

View File

@ -4,13 +4,14 @@
class CreateAttachmentFromUrlJob < ApplicationJob
queue_as :default
def perform(image:, attachment_url:)
image
.device
.auto_sync_transaction do
def perform(image_id:, attachment_url:)
image = Image.find_by(id: image_id)
if image
image.device.auto_sync_transaction do
image.set_attachment_by_url(attachment_url)
image.save!
end
end
end
def max_attempts

View File

@ -5,6 +5,7 @@ class KeyGen
PROD_KEY_FILE = "/keys/production.pem"
KEY_FILE = "jwt.#{Rails.env}.pem"
SAVE_PATH = (Rails.env == "production") ? PROD_KEY_FILE : KEY_FILE
# SAVE_PATH = KEY_FILE
def self.try_file
OpenSSL::PKey::RSA.new(File.read(SAVE_PATH)) if File.file?(SAVE_PATH)

View File

@ -1,16 +1,18 @@
class FatalErrorMailer < ApplicationMailer
def fatal_error(device, log)
@emails = device.users.pluck(:email)
@logs = device
.logs
.where(Log::IS_FATAL_EMAIL)
.where(sent_at: nil)
return if @logs.empty?
@message = @logs
.pluck(:message)
.join("\n\n")
@device_name = device.name || "Farmbot"
mail(to: @emails, subject: "🚨 New error reported by #{@device_name}!")
@logs.update_all(sent_at: Time.now)
Log.transaction do
@emails = device.users.pluck(:email)
@logs = device
.logs
.where(Log::IS_FATAL_EMAIL)
.where(sent_at: nil)
return if @logs.empty?
@message = @logs
.pluck(:message)
.join("\n\n")
@device_name = device.name || "Farmbot"
mail(to: @emails, subject: "🚨 New error reported by #{@device_name}!")
@logs.update_all(sent_at: Time.now)
end
end
end

View File

@ -2,21 +2,23 @@ class LogDeliveryMailer < ApplicationMailer
WHOAH = "Device %s is sending too many emails!!! (> 20 / hr)"
def log_digest(device)
query_params = { sent_at: 1.hours.ago..Time.now, device_id: device.id }
sent_this_hour = LogDispatch.where(query_params).count
too_many = sent_this_hour > LogDispatch.max_per_hour
raise LogDispatch::RateLimitError, WHOAH % [device.id] if too_many
ld = LogDispatch.where(sent_at: nil, device: device)
if(ld.any?)
logs = Log
.where(id: ld.pluck(:log_id))
.where
.not(Log::IS_FATAL_EMAIL)
@emails = device.users.pluck(:email)
@messages = logs.map(&:message)
@device_name = device.name || "Farmbot"
mail(to: @emails, subject: "🌱 New message from #{@device_name}!")
ld.update_all(sent_at: Time.now)
Log.transaction do
query_params = { sent_at: 1.hours.ago..Time.now, device_id: device.id }
sent_this_hour = Log.where(query_params).count
too_many = sent_this_hour > Log.max_per_hour
raise Log::RateLimitError, WHOAH % [device.id] if too_many
unsent = Log.where(sent_at: nil, device: device)
if(unsent.any?)
logs = Log
.where(id: unsent.pluck(:id))
.where
.not(Log::IS_FATAL_EMAIL)
@emails = device.users.pluck(:email)
@messages = logs.map(&:message)
@device_name = device.name || "Farmbot"
mail(to: @emails, subject: "🌱 New message from #{@device_name}!")
unsent.update_all(sent_at: Time.now)
end
end
end
end

View File

@ -18,7 +18,7 @@ module CeleryScriptSettingsBag
install_farmware update_farmware take_photo zero
install_first_party_farmware remove_farmware
find_home register_gpio unregister_gpio
set_servo_angle change_ownership)
set_servo_angle change_ownership dump_info)
ALLOWED_PACKAGES = %w(farmbot_os arduino_firmware)
ALLOWED_CHAGES = %w(add remove update)
RESOURCE_NAME = %w(images plants regimens peripherals
@ -227,6 +227,7 @@ module CeleryScriptSettingsBag
.node(:parameter_declaration, [:label, :data_type], [])
.node(:set_servo_angle, [:pin_number, :pin_value], [])
.node(:change_ownership, [], [:pair])
.node(:dump_info, [], [])
.node(:install_first_party_farmware, [])
ANY_ARG_NAME = Corpus.as_json[:args].pluck("name").map(&:to_s)

View File

@ -12,24 +12,25 @@ class Device < ApplicationRecord
"Resuming log storage."
CACHE_KEY = "devices.%s"
has_many :device_configs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :device_configs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :farmware_installations, dependent: :destroy
has_many :images, dependent: :destroy
has_many :logs, dependent: :destroy
has_many :peripherals, dependent: :destroy
has_many :pin_bindings, dependent: :destroy
has_many :plant_templates, dependent: :destroy
has_many :points, dependent: :destroy
has_many :regimens, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :sensor_readings, dependent: :destroy
has_many :sensors, dependent: :destroy
has_many :sequences, dependent: :destroy
has_many :token_issuances, dependent: :destroy
has_many :tools, dependent: :destroy
has_many :webcam_feeds, dependent: :destroy
has_one :fbos_config, dependent: :destroy
has_many :images, dependent: :destroy
has_many :logs, dependent: :destroy
has_many :peripherals, dependent: :destroy
has_many :pin_bindings, dependent: :destroy
has_many :plant_templates, dependent: :destroy
has_many :points, dependent: :destroy
has_many :regimens, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :sensor_readings, dependent: :destroy
has_many :sensors, dependent: :destroy
has_many :sequences, dependent: :destroy
has_many :token_issuances, dependent: :destroy
has_many :tools, dependent: :destroy
has_many :webcam_feeds, dependent: :destroy
has_many :diagnostic_dumps, dependent: :destroy
has_one :fbos_config, dependent: :destroy
has_many :in_use_tools
has_many :in_use_points

View File

@ -0,0 +1,3 @@
class DiagnosticDump < ApplicationRecord
belongs_to :device
end

View File

@ -44,4 +44,17 @@ class Image < ApplicationRecord
self.attachment_processed_at = Time.now
self
end
# Scenario:
# User clicks "take photo" and "delete" on Image#123 very quickly.
# Problem:
# Now there's a Delayed::Job pointing to (nonexistent) Image#123,
# causing runtime errrors in the work queue.
# Solution:
# Don't retry failed deletions. Users can always click the "delete"
# button again if need be.
def self.maybe_destroy(id)
image = find_by(id: id)
image.destroy! if image
end
end

View File

@ -1,6 +1,7 @@
# A device will emit logs when events occur on the Raspberry Pi. Logs are then
# read by clients. Logs are only created by devices.
class Log < ApplicationRecord
include LogDeliveryStuff
# We use log.type to store the log's type.
# Rails wants to use that name for single table inheritence, which we don't
# need for this table.
@ -20,11 +21,10 @@ class Log < ApplicationRecord
validates :device, presence: true
validates :type, presence: true
serialize :meta
serialize :meta
validates :meta, presence: true
# http://stackoverflow.com/a/5127684/1064917
before_validation :set_defaults
has_one :log_dispatch, dependent: :destroy
def set_defaults
self.channels ||= []

View File

@ -0,0 +1,39 @@
# Prevents spamming a user when a malfunctioning Farmware tries to send
# 200000 emails in 4 seconds.
# Also helps group "fast" messages into a digest.
module LogDeliveryStuff
class RateLimitError < StandardError; end
WAIT_PERIOD = 30
WAIT_UNIT = :seconds
module ClassMethods
attr_accessor :max_per_hour
# If this method grows, create a mutation.
def deliver(device, log)
send_routine_emails(log, device)
send_fatal_emails(log, device)
end
def digest_wait_time
{ wait: WAIT_PERIOD.send(WAIT_UNIT) }
end
# TODO: Why must I explicitly pass `mailer_klass`? Somethings not right with
# mocks.
def send_routine_emails(log, device, mailer_klass = LogDeliveryMailer)
return unless (log.channels || []).include?("email")
mailer_klass.log_digest(device).deliver_later(digest_wait_time)
end
def send_fatal_emails(log, device)
return unless (log.channels || []).include?("fatal_email")
FatalErrorMailer.fatal_error(device, log).deliver_later
end
end
def self.included(receiver)
receiver.extend(ClassMethods)
receiver.max_per_hour = 20
end
end

View File

@ -1,42 +0,0 @@
# Prevents spamming a user when a malfunctioning Farmware tries to send
# 200000 emails in 4 seconds.
# Also helps group "fast" messages into a digest.
class LogDispatch < ApplicationRecord
class RateLimitError < StandardError; end
belongs_to :device
belongs_to :log
class_attribute :max_per_hour
self.max_per_hour = 20
WAIT_PERIOD = 30
WAIT_UNIT = :seconds
# If this method grows, create a mutation.
def self.deliver(device, log)
send_routine_emails(log, device)
send_fatal_emails(log, device)
end
def self.digest_wait_time
{ wait: WAIT_PERIOD.send(WAIT_UNIT) }
end
# TODO: Why must I explicitly pass `mailer_klass`? Somethings not right with
# mocks.
def self.send_routine_emails(log, device, mailer_klass = LogDeliveryMailer)
return unless (log.channels || []).include?("email")
self.create!(device: device, log: log)
mailer_klass.log_digest(device).deliver_later(digest_wait_time)
end
def self.send_fatal_emails(log, device)
return unless (log.channels || []).include?("fatal_email")
FatalErrorMailer.fatal_error(device, log).deliver_later
end
def broadcast?
false
end
end

View File

@ -2,10 +2,14 @@ require "bunny"
# A wrapper around AMQP to stay DRY. Will make life easier if we ever need to
# change protocols
class Transport
LOCAL = "amqp://guest:guest@localhost:5672"
AMQP_URL = ENV['CLOUDAMQP_URL'] || ENV['RABBITMQ_URL'] || LOCAL
OPTS = { read_timeout: 10, heartbeat: 10, log_level: 'info' }
def self.amqp_url
@amqp_url ||= ENV['CLOUDAMQP_URL'] ||
ENV['RABBITMQ_URL'] ||
"amqp://admin:#{ENV.fetch("ADMIN_PASSWORD")}@localhost:5672"
end
def self.default_amqp_adapter=(value)
@default_amqp_adapter = value
end
@ -25,14 +29,15 @@ class Transport
end
def connection
@connection ||= Transport.default_amqp_adapter.new(AMQP_URL, OPTS).start
@connection ||= Transport
.default_amqp_adapter.new(Transport.amqp_url, OPTS).start
end
def log_channel
@log_channel ||= self.connection
.create_channel
.queue("", exclusive: true)
.bind("amq.topic", routing_key: "bot.*.logs")
@log_channel ||= self.connection
.create_channel
.queue("api_log_workers")
.bind("amq.topic", routing_key: "bot.*.logs")
end
def amqp_topic

View File

@ -38,4 +38,15 @@ class User < ApplicationRecord
def verified?
SKIP_EMAIL_VALIDATION ? true : !!confirmed_at
end
def self.admin_user
@admin_user ||= self.find_or_create_by(email: "admin@admin.com") do |u|
u.name = "Administrator"
u.password = ENV.fetch("ADMIN_PASSWORD")
u.password_confirmation = ENV.fetch("ADMIN_PASSWORD")
u.confirmed_at = Time.now
u.agreed_to_terms_at = Time.now
u.device_id = Devices::Create.run!(user: u).id
end
end
end

View File

@ -1,7 +1,7 @@
module Devices
class Create < Mutations::Command
required do
model :user, class: User
model :user, class: User, new_records: true
end
optional do

View File

@ -0,0 +1,25 @@
module DiagnosticDumps
class Create < Mutations::Command
required do
model :device, class: Device
string :fbos_version
string :fbos_commit
string :firmware_commit
string :network_interface
string :fbos_dmesg_dump
string :firmware_state
end
def execute
DiagnosticDump
.create!(device: device,
ticket_identifier: rand(36**5).to_s(36),
fbos_version: fbos_version,
fbos_commit: fbos_commit,
firmware_commit: firmware_commit,
network_interface: network_interface,
fbos_dmesg_dump: fbos_dmesg_dump,
firmware_state: firmware_state,)
end
end
end

View File

@ -2,7 +2,7 @@ module Images
class Create < Mutations::Command
required do
string :attachment_url
model :device, class: Device
model :device, class: Device
end
optional do
@ -18,7 +18,7 @@ module Images
def execute
i = Image.create!(inputs.except(:attachment_url))
CreateAttachmentFromUrlJob.perform_later(image: i,
CreateAttachmentFromUrlJob.perform_later(image_id: i.id,
attachment_url: attachment_url)
i
end

View File

@ -39,6 +39,7 @@ module Logs
integer :verbosity
integer :major_version
integer :minor_version
integer :created_at
hash :meta do # This can be transitioned out soon.
string :type, in: Log::TYPES
@ -68,6 +69,7 @@ module Logs
@log.major_version = transitional_field(:major_version)
@log.minor_version = transitional_field(:minor_version)
@log.type = transitional_field(:type, "info")
@log.created_at = DateTime.strptime(created_at.to_s,'%s') if created_at
@log.validate!
end
@ -79,7 +81,7 @@ module Logs
private
def maybe_deliver
LogDispatch.delay.deliver(device, @log)
Log.delay.deliver(device, @log)
end
def has_bad_words

View File

@ -0,0 +1,5 @@
class DiagnosticDumpSerializer < ActiveModel::Serializer
attributes :id, :device_id, :ticket_identifier, :fbos_commit, :fbos_version,
:firmware_commit, :firmware_state, :network_interface,
:fbos_dmesg_dump, :created_at, :updated_at
end

View File

@ -1,3 +1,3 @@
class SensorReadingSerializer < ActiveModel::Serializer
attributes :id, :pin, :value, :x, :y, :z
attributes :id, :created_at, :pin, :value, :x, :y, :z
end

View File

@ -2,7 +2,7 @@
var hasInclude = !!Array.prototype.includes
var user;
try {
user = (JSON.parse(localStorage.session || "{}").user);
user = {user_id: (JSON.parse(localStorage.session || "{}").user || {}).id || 0};
} catch(e) {
};
<% if ENV["ROLLBAR_ACCESS_TOKEN"] && ENV["ROLLBAR_CLIENT_TOKEN"] %>

View File

@ -43,7 +43,7 @@ DEPS = `yarn outdated`
.map{|y| y.split }
.map{|y| "#{y[0]}@#{y[3]}"}
.sort
.reject { |x| x.include?("router") }
# puts "Making sure that type checks pass WITHOUT any upgrades"
tc_ok = type_check

View File

@ -15,10 +15,6 @@
# SERVER WONT WORK IF YOU FORGET TO DELETE THIS EXAMPLE TEXT BELOW.
# ADD A REAL RSA_KEY OR DELETE THIS LINE!!
RSA_KEY: "Change this! Keys look like `-----BEGIN RSA .........`"
# If you use Let's Encrypt for SSL,
# you must set this when renewing SSL.
# Otherwise, not required and CAN BE REMOVED.
ACME_SECRET: "-----"
# If your server is on a domain (eg: my-own-farmbot.com), put it here.
# DONT USE `localhost`.
# DONT USE `127.0.0.1`.
@ -59,8 +55,6 @@ HEROKU_SLUG_COMMIT: "This is set by Heroku, used by Frontend to show current ver
# Use a REAL IP ADDRESS if you are controlling real bots.
# 0.0.0.0 is only OK for software testing. Change this!
MQTT_HOST: "98.76.54.32"
# Delete this line if you are not an employee of FarmBot, Inc.
NPM_ADDON: "Used by FarmBot, Inc. to load proprietary extras, like Rollbar."
# Same as above. Can be deleted unless you are a Rollbar.IO customer.
ROLLBAR_ACCESS_TOKEN: "____"
ROLLBAR_CLIENT_TOKEN: "____"
@ -79,10 +73,6 @@ NO_EMAILS: "TRUE"
# If you are not using the standard MQTT broker (eg: you use a 3rd party
# MQTT vendor), you will need to change this line.
MQTT_WS: "ws://DELETE_OR_CHANGE_THIS_LINE/ws"
# ENV var used by FarmBot employees when building different versions of the JWT
# auth backend plugin.
# Can be deleted safely.
API_PUBLIC_KEY_PATH: "http://changeme.io/api/public_key"
# If you are using a shared RabbitMQ server and need to use a VHost other than
# "/", change this ENV var.
MQTT_VHOST: "/"
@ -97,4 +87,7 @@ EXTRA_DOMAINS: staging.farm.bot,whatever.farm.bot
RUN_CAPYBARA: "true"
# Set this to "production" in most cases.
# If you need help debugging issues, please delete this line.
RAILS_ENV: "production"
RAILS_ENV: "production"
# Every server has a superuser.
# Set this to something SECURE.
ADMIN_PASSWORD: ""

View File

@ -11,7 +11,7 @@ if ENV["ROLLBAR_ACCESS_TOKEN"]
Rollbar.configure do |config|
config.access_token = ENV["ROLLBAR_ACCESS_TOKEN"]
config.enabled = Rails.env.production? ? true : false
config.person_method = "current_device"
config.person_method = "current_device_id"
config.environment = (ENV["API_HOST"] || $API_URL || ENV["ROLLBAR_ENV"] || Rails.env)
end
end

View File

@ -1,7 +1,13 @@
FarmBot::Application.routes.draw do
namespace :api, defaults: {format: :json}, constraints: { format: "json" } do
post "/rmq/user" => "rmq_utils#user", as: "rmq_user"
post "/rmq/vhost" => "rmq_utils#vhost", as: "rmq_vhost"
post "/rmq/resource" => "rmq_utils#resource", as: "rmq_resource"
post "/rmq/topic" => "rmq_utils#topic", as: "rmq_topic"
# Standard API Resources:
{
diagnostic_dumps: [:create, :destroy, :index],
farm_events: [:create, :destroy, :index, :update],
farmware_installations: [:create, :destroy, :index],
images: [:create, :destroy, :index, :show],

View File

@ -1,51 +0,0 @@
var StatsPlugin = require('stats-webpack-plugin');
module.exports = function () {
return {
entry: {
"app_bundle": "./webpack/entry.tsx",
"front_page": "./webpack/front_page/index.tsx",
"password_reset": "./webpack/password_reset/index.tsx",
"tos_update": "./webpack/tos_update/index.tsx"
},
// Was "eval", but that did not go well with our CSP
devtool: "cheap-module-source-map",
module: {
rules: [
{
test: [/\.scss$/, /\.css$/],
use: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.tsx?$/,
use: "ts-loader"
},
{
test: [/\.woff$/, /\.woff2$/, /\.ttf$/],
use: "url-loader"
},
{
test: [/\.eot$/, /\.svg(\?v=\d+\.\d+\.\d+)?$/],
use: "file-loader"
}
]
},
// Allows imports without file extensions.
resolve: {
extensions: [".js", ".ts", ".tsx", ".css", ".scss", ".json"]
},
plugins: [
new StatsPlugin('manifest.json', {
// We only need assetsByChunkName
chunkModules: false,
source: false,
chunks: false,
modules: false,
assets: true
})
],
node: {
fs: "empty"
}
};
}

View File

@ -1,27 +1,68 @@
var path = require("path");
var genConfig = require("./webpack.base");
var conf = genConfig();
var StatsPlugin = require('stats-webpack-plugin');
var host = process.env["API_HOST"] || "localhost"
var devServerPort = 3808;
const host = process.env["API_HOST"] || "localhost"
conf.mode = "development";
conf.output = {
// must match config.webpack.output_dir
path: path.join(__dirname, '..', 'public', 'webpack'),
publicPath: `//${host}:${devServerPort}/webpack/`,
filename: '[name].js'
};
conf.devServer = {
port: devServerPort,
disableHostCheck: true,
watchOptions: {
aggregateTimeout: 300,
poll: 1000
module.exports = {
mode: "none",
output: {
// must match config.webpack.output_dir
path: path.join(__dirname, '..', 'public', 'webpack'),
publicPath: `//${host}:${devServerPort}/webpack/`,
filename: '[name].js'
},
host: "0.0.0.0",
headers: { 'Access-Control-Allow-Origin': '*' }
entry: {
"app_bundle": "./webpack/entry.tsx",
"front_page": "./webpack/front_page/index.tsx",
"password_reset": "./webpack/password_reset/index.tsx",
"tos_update": "./webpack/tos_update/index.tsx"
},
devtool: "eval",
module: {
rules: [
{
test: [/\.scss$/, /\.css$/],
use: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.tsx?$/,
use: "ts-loader"
},
{
test: [/\.woff$/, /\.woff2$/, /\.ttf$/],
use: "url-loader"
},
{
test: [/\.eot$/, /\.svg(\?v=\d+\.\d+\.\d+)?$/],
use: "file-loader"
}
]
},
// Allows imports without file extensions.
resolve: {
extensions: [".js", ".ts", ".tsx", ".css", ".scss", ".json"]
},
plugins: [
new StatsPlugin('manifest.json', {
// We only need assetsByChunkName
chunkModules: false,
source: false,
chunks: false,
modules: false,
assets: true
})
],
node: {
fs: "empty"
},
devServer: {
port: devServerPort,
disableHostCheck: true,
watchOptions: {
aggregateTimeout: 300,
poll: 1000
},
host: "0.0.0.0",
headers: { 'Access-Control-Allow-Origin': '*' }
}
};
module.exports = conf;

View File

@ -1,48 +1,90 @@
'use strict';
global.WEBPACK_ENV = "production";
// var ExtractTextPlugin = require("extract-text-webpack-plugin");
var path = require("path");
var genConfig = require("./webpack.base");
var UglifyJsPlugin = require("webpack-uglify-js-plugin");
var OptimizeCssAssetsPlugin = require("optimize-css-assets-webpack-plugin");
var webpack = require("webpack");
var StatsPlugin = require('stats-webpack-plugin');
var publicPath = '/webpack/';
var conf = genConfig();
conf.mode = "production";
conf.output = {
path: path.join(__dirname, '..', 'public', 'webpack'),
publicPath: '/webpack/',
filename: '[name]-[chunkhash].js',
chunkFilename: '[id].[chunkhash].js'
var conf = {
mode: "none",
devtool: "source-map",
entry: {
"app_bundle": "./webpack/entry.tsx",
"front_page": "./webpack/front_page/index.tsx",
"password_reset": "./webpack/password_reset/index.tsx",
"tos_update": "./webpack/tos_update/index.tsx"
},
output: {
path: path.join(__dirname, '..', 'public', 'webpack'),
publicPath,
filename: '[name]-[chunkhash].js',
chunkFilename: '[id].[name].[chunkhash].js'
},
module: {
rules: [
{
test: [/\.scss$/, /\.css$/],
use: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.tsx?$/,
use: "ts-loader"
},
{
test: [/\.woff$/, /\.woff2$/, /\.ttf$/],
use: "url-loader"
},
{
test: [/\.eot$/, /\.svg(\?v=\d+\.\d+\.\d+)?$/],
use: "file-loader"
}
]
},
// Allows imports without file extensions.
resolve: {
extensions: [".js", ".ts", ".tsx", ".css", ".scss", ".json"]
},
plugins: [
new StatsPlugin('manifest.json', {
// We only need assetsByChunkName
chunkModules: false,
source: false,
chunks: false,
modules: false,
assets: true
}),
new OptimizeCssAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require("cssnano"),
cssProcessorOptions: { discardComments: { removeAll: true } },
canPrint: true
}),
new UglifyJsPlugin({
cacheFolder: path.resolve(__dirname, "../public/dist/cached_uglify/"),
debug: true,
minimize: true,
sourceMap: true,
screw_ie8: true,
output: { comments: false },
compressor: { warnings: false }
}),
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify("production")
}
})
],
node: {
fs: "empty"
}
};
[
// new ExtractTextPlugin({
// filename: "dist/[name].[chunkhash].css",
// disable: false,
// allChunks: true
// }),
new OptimizeCssAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require("cssnano"),
cssProcessorOptions: { discardComments: { removeAll: true } },
canPrint: true
}),
new UglifyJsPlugin({
cacheFolder: path.resolve(__dirname, "../public/dist/cached_uglify/"),
debug: true,
minimize: true,
sourceMap: true,
screw_ie8: true,
output: { comments: false },
compressor: { warnings: false }
}),
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify("production")
}
})
].map(x => conf.plugins.push(x));
var accessToken = process.env.ROLLBAR_ACCESS_TOKEN
if (accessToken) {
var RollbarSourceMapPlugin = require('rollbar-sourcemap-webpack-plugin')
var version = process.env.BUILT_AT || process.env.HEROKU_SLUG_COMMIT || "????"
var plugin = new RollbarSourceMapPlugin({accessToken, version, publicPath})
conf.plugins.push(plugin)
}
module.exports = conf;

View File

@ -0,0 +1,14 @@
class GetRidOfLogDispatches < ActiveRecord::Migration[5.2]
def change
drop_table :log_dispatches do |t|
t.bigint :device_id
t.bigint :log_id
t.datetime :sent_at
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
# If we don't do this, a storm of emails will hit every user.
# Relates to the deprecation of the `LogDispatch` table. - RC, 9 JUN 18
Log.where("created_at < ?", 1.hour.ago).update_all(sent_at: 2.hours.ago)
end
end

View File

@ -0,0 +1,16 @@
class CreateDiagnosticDumps < ActiveRecord::Migration[5.2]
def change
create_table :diagnostic_dumps do |t|
t.references :device, foreign_key: true, null: false
t.string :ticket_identifier, null: false, unique: true
t.string :fbos_commit, null: false
t.string :fbos_version, null: false
t.string :firmware_commit, null: false
t.string :firmware_state, null: false
t.string :network_interface, null: false
t.text :fbos_dmesg_dump, null: false
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_06_06_131907) do
ActiveRecord::Schema.define(version: 2018_06_15_153318) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
@ -53,6 +53,20 @@ ActiveRecord::Schema.define(version: 2018_06_06_131907) do
t.index ["timezone"], name: "index_devices_on_timezone"
end
create_table "diagnostic_dumps", force: :cascade do |t|
t.bigint "device_id", null: false
t.string "ticket_identifier", null: false
t.string "fbos_commit", null: false
t.string "fbos_version", null: false
t.string "firmware_commit", null: false
t.string "firmware_state", null: false
t.string "network_interface", null: false
t.text "fbos_dmesg_dump", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["device_id"], name: "index_diagnostic_dumps_on_device_id"
end
create_table "edge_nodes", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -227,17 +241,6 @@ ActiveRecord::Schema.define(version: 2018_06_06_131907) do
t.index ["device_id"], name: "index_images_on_device_id"
end
create_table "log_dispatches", force: :cascade do |t|
t.bigint "device_id"
t.bigint "log_id"
t.datetime "sent_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["device_id"], name: "index_log_dispatches_on_device_id"
t.index ["log_id"], name: "index_log_dispatches_on_log_id"
t.index ["sent_at"], name: "index_log_dispatches_on_sent_at"
end
create_table "logs", id: :serial, force: :cascade do |t|
t.text "message"
t.text "meta"
@ -489,10 +492,9 @@ ActiveRecord::Schema.define(version: 2018_06_06_131907) do
end
add_foreign_key "device_configs", "devices"
add_foreign_key "diagnostic_dumps", "devices"
add_foreign_key "edge_nodes", "sequences"
add_foreign_key "farmware_installations", "devices"
add_foreign_key "log_dispatches", "devices"
add_foreign_key "log_dispatches", "logs"
add_foreign_key "peripherals", "devices"
add_foreign_key "pin_bindings", "devices"
add_foreign_key "pin_bindings", "sequences"

View File

@ -6,7 +6,6 @@ unless Rails.env == "production"
ENV['MQTT_HOST'] = "blooper.io"
ENV['OS_UPDATE_SERVER'] = "http://non_legacy_update_url.com"
LogDispatch.destroy_all
Log.destroy_all
TokenIssuance.destroy_all
PinBinding.destroy_all
@ -21,23 +20,17 @@ unless Rails.env == "production"
User.destroy_all
PlantTemplate.destroy_all
SavedGarden.destroy_all
Users::Create.run!(name: "Administrator",
email: "farmbot@farmbot.io",
User.admin_user
Users::Create.run!(name: "Test",
email: "test@test.com",
password: "password123",
password_confirmation: "password123",
agree_to_terms: true)
signed_tos = User.last
signed_tos.agreed_to_terms_at = nil
signed_tos.confirmed_at = Time.now
signed_tos.save(validate: false)
Users::Create.run!(name: "Administrator",
email: "admin@admin.com",
password: "password123",
password_confirmation: "password123",
agree_to_terms: true)
confirmed_at: Time.now,
agreed_to_terms_at: Time.now)
User.all.update_all(confirmed_at: Time.now,
agreed_to_terms_at: Time.now)
u = User.last
u.update_attributes(confirmed_at: Time.now)
u.update_attributes(device: Devices::Create.run!(user: u))
Log.transaction do
FactoryBot.create_list(:log, 35, device: u.device)
end
@ -117,8 +110,6 @@ unless Rails.env == "production"
y: 10,
z: 10)
d = u.device
# PinBindings::Create
# .run!(device: d, sequence_id: d.sequences.sample.id, pin_num: 15,)
Sensors::Create
.run!(device: d, pin: 14, label: "Stub sensor", mode: 0)
end

View File

@ -83,7 +83,7 @@ class CorpusEmitter
end
end
HASH = JSON.load(open("http://localhost:3000/api/corpuses/3")).deep_symbolize_keys
HASH = JSON.load(open("http://localhost:3000/api/corpus")).deep_symbolize_keys
ARGS = {}
HASH[:args].map{ |x| CSArg.new(x) }.each{|x| ARGS[x.name] = x}
NODES = HASH[:nodes].map { |x| CSNode.new(x) }

View File

@ -6,9 +6,10 @@ begin
.current
.log_channel
.subscribe(block: true) do |info, _, payl|
LogService.process(info, payl)
LogService.process(info, payl.force_encoding("UTF-8"))
end
rescue StandardError => e
Rollbar.error(e)
puts "MQTT Broker is unreachable. Waiting 5 seconds..."
sleep 5
retry

View File

@ -27,7 +27,7 @@ class LogService
def self.deliver(data)
dev, log = [data.device, data.payload]
dev.maybe_unthrottle
LogDispatch.deliver(dev, Logs::Create.run!(log, device: dev))
Log.deliver(dev, Logs::Create.run!(log, device: dev))
end
def self.warn_user(data, violation)

View File

@ -4,7 +4,7 @@ namespace :mqtt do
task start: :environment do
begin
require_relative '../../mqtt/server.rb'
rescue => Errno::ECONNREFUSED
rescue Errno::ECONNREFUSED => e
puts "API is not up yet. Waiting 5 seconds..."
sleep 5
retry

View File

@ -1,6 +1,9 @@
FROM rickcarlino/rmq_base:rc1
# TODO: Move this into base image on dockerhub
ADD ./rabbitmq.config /etc/rabbitmq/
FROM rabbitmq:3.7.6
ADD ./rabbitmq.conf /etc/rabbitmq/
RUN rabbitmq-plugins enable --offline \
rabbitmq_auth_backend_http \
rabbitmq_management \
rabbitmq_mqtt \
rabbitmq_auth_backend_cache \
rabbitmq_web_mqtt
CMD ["rabbitmq-server"]

View File

@ -0,0 +1,15 @@
auth_backends.1 = http
auth_cache.cached_backend = http
auth_cache.cache_ttl = 600000
auth_http.http_method = post
auth_http.resource_path = <%= fully_formed_url %>/api/rmq/resource
auth_http.topic_path = <%= fully_formed_url %>/api/rmq/topic
auth_http.user_path = <%= fully_formed_url %>/api/rmq/user
auth_http.vhost_path = <%= fully_formed_url %>/api/rmq/vhost
default_user = "admin"
default_pass = <%= admin_password %>
mqtt.allow_anonymous = false

View File

@ -1,30 +0,0 @@
% THIS FILE IS AUTO GENERATED BY `rabbitmq.config.erb`.
% DO NOT MODIFY `rabbitmq.config` MANUALLY!
[
{
rabbit, [
{ loopback_users, [] },
{ tcp_listeners, [5672] },
{ ssl_listeners, [ ] },
{ hipe_compile, false },
{
auth_backends, [
rabbit_auth_backend_internal,
rabbit_auth_backend_jwt
]}
] },
{ rabbitmq_mqtt, [
{vhost, <<"<%= VHOST %>">>},
{retained_message_store, rabbit_mqtt_retained_msg_store_noop}
]
},
{ rabbitmq_web_mqtt, [{ port, 3002 }, {vhost, <<"<%= VHOST %>">>}]},
{
rabbitmq_management, [{listener, [ { port, 15672 }, { ssl, false }] } ] },
{ rabbit_auth_backend_jwt, [
{ farmbot_api_key_url, "<%= farmbot_api_key_url %>" },
{ farmbot_vhost, <<"<%= VHOST %>">>}
]}
].

View File

@ -1,35 +1,36 @@
require 'erb'
def needs_admin_password
raise "You must set an ADMIN_PASSWORD in application.yml."
end
puts "=== Retrieving container info"
PLUGIN_PATH = "mqtt/jwt_plugin/plugins/rabbit_auth_backend*"
PLUGIN_IS_BUILT = Dir[PLUGIN_PATH].any?
FORCE_REBUILD = ENV["FORCE_REBUILD"].present?
DOCKER_IMG_NAME = "farmbot-mqtt"
IMG_IS_BUILT = `cd mqtt; sudo docker images`.include?(DOCKER_IMG_NAME)
puts "=== Setting config data"
CONFIG_PATH = "./mqtt"
CONFIG_FILENAME = "rabbitmq.config"
CONFIG_OUTPUT = "#{CONFIG_PATH}/#{CONFIG_FILENAME}"
NO_API_HOST = "\nYou MUST set API_HOST to a real IP address or " +
"domain name (not localhost).\n" +
"API_PORT is also mandatory."
TEMPLATE_FILE = "./mqtt/rabbitmq.config.erb"
TEMPLATE = File.read(TEMPLATE_FILE)
RENDERER = ERB.new(TEMPLATE)
PROTO = ENV["FORCE_SSL"] ? "https:" : "http:"
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
CONFIG_PATH = "./mqtt"
CONFIG_FILENAME = "rabbitmq.conf"
CONFIG_OUTPUT = "#{CONFIG_PATH}/#{CONFIG_FILENAME}"
NO_API_HOST = "\nYou MUST set API_HOST to a real IP address or " +
"domain name (not localhost).\n" +
"API_PORT is also mandatory."
TEMPLATE_FILE = "./mqtt/rabbitmq.conf.erb"
TEMPLATE = File.read(TEMPLATE_FILE)
RENDERER = ERB.new(TEMPLATE)
PROTO = ENV["FORCE_SSL"] ? "https:" : "http:"
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
admin_password = ENV.fetch("ADMIN_PASSWORD") { needs_admin_password }.inspect
needs_admin_password if admin_password.length < 5
fully_formed_url = PROTO + $API_URL
if !ENV["API_HOST"] || !ENV["API_PORT"]
puts NO_API_HOST
exit
end
puts "=== Building JWT plugin config"
farmbot_api_key_url = ENV.fetch("API_PUBLIC_KEY_PATH") do
"#{PROTO}#{$API_URL}/api/public_key"
end
# Write the config file.
File.write(CONFIG_OUTPUT, RENDERER.result(binding))

View File

@ -34,11 +34,11 @@
"@types/fastclick": "^1.0.28",
"@types/history": "^4.6.1",
"@types/i18next": "^8.4.2",
"@types/jest": "23.0.0",
"@types/lodash": "4.14.109",
"@types/jest": "23.1.1",
"@types/lodash": "4.14.110",
"@types/markdown-it": "^0.0.4",
"@types/moxios": "^0.4.5",
"@types/node": "10.3.1",
"@types/node": "10.3.5",
"@types/react": "16.3.14",
"@types/react-color": "2.13.5",
"@types/react-dom": "16.0.5",
@ -51,13 +51,13 @@
"css-loader": "0.28.11",
"enzyme": "^3.1.0",
"enzyme-adapter-react-16": "^1.1.0",
"farmbot": "6.0.0-rc2",
"farmbot": "6.0.1",
"farmbot-toastr": "^1.0.3",
"fastclick": "^1.0.6",
"file-loader": "1.1.11",
"i18next": "11.3.2",
"i18next": "11.3.3",
"imports-loader": "0.8.0",
"jest": "23.1.0",
"jest": "23.2.0",
"json-loader": "0.5.7",
"lodash": "4.17.10",
"markdown-it": "^8.4.0",
@ -67,36 +67,37 @@
"node-sass": "4.9.0",
"optimize-css-assets-webpack-plugin": "4.0.2",
"raf": "^3.4.0",
"react": "16.4",
"react": "16.4.1",
"react-addons-css-transition-group": "^15.6.2",
"react-addons-test-utils": "^15.6.2",
"react-color": "2.14.1",
"react-dom": "16.4",
"react-dom": "16.4.1",
"react-redux": "^5.0.6",
"react-router": "^3",
"react-test-renderer": "16.4.0",
"react-test-renderer": "16.4.1",
"react-transition-group": "^2.3.1",
"redux": "^3.7.2",
"redux-immutable-state-invariant": "^2.1.0",
"redux-thunk": "^2.0.1",
"rollbar-sourcemap-webpack-plugin": "^2.3.0",
"sass-loader": "7.0.3",
"stats-webpack-plugin": "0.6.2",
"style-loader": "0.21.0",
"ts-jest": "22.4.6",
"ts-lint": "^4.5.1",
"ts-loader": "4.3.1",
"ts-loader": "^4.4.1",
"tslint": "5.10.0",
"typescript": "2.9.1",
"typescript": "2.9.2",
"url-loader": "1.0.1",
"webpack": "4.11.1",
"webpack": "4.12.1",
"webpack-uglify-js-plugin": "1.1.9",
"weinre": "^2.0.0-pre-I0Z7U9OV",
"which": "1.3.1",
"yarn": "^1.7.0"
},
"devDependencies": {
"jscpd": "0.6.18",
"webpack-cli": "3.0.2",
"jscpd": "0.6.22",
"webpack-cli": "3.0.8",
"webpack-notifier": "^1.5.0"
},
"jest": {

View File

@ -0,0 +1,52 @@
require 'spec_helper'
describe Api::DiagnosticDumpsController do
let(:device) { FactoryBot.create(:device) }
let(:user) { FactoryBot.create(:user, device: device) }
include Devise::Test::ControllerHelpers
it 'lists all diagnostics' do
sign_in user
DiagnosticDump.destroy_all
device_config = FactoryBot.create_list(:diagnostic_dump, 3, device: device)
get :index
expect(json.length).to eq(3)
expect(json.pluck(:device_id).uniq).to eq([user.device.id])
end
it 'creates a dump' do
sign_in user
DiagnosticDump.destroy_all
b4 = DiagnosticDump.count
input = {
fbos_version: "123_fbos_version",
fbos_commit: "123_fbos_commit",
firmware_commit: "123_firmware_commit",
network_interface: "123_network_interface",
fbos_dmesg_dump: "123_fbos_dmesg_dump",
firmware_state: "123_firmware_state",
}
post :create, body: input.to_json
expect(response.status).to eq(200)
expect(DiagnosticDump.count).to be > b4
expect(DiagnosticDump.last.device).to eq(device)
expect(json[:fbos_version]).to eq("123_fbos_version")
expect(json[:fbos_commit]).to eq("123_fbos_commit")
expect(json[:firmware_commit]).to eq("123_firmware_commit")
expect(json[:network_interface]).to eq("123_network_interface")
expect(json[:fbos_dmesg_dump]).to eq("123_fbos_dmesg_dump")
expect(json[:firmware_state]).to eq("123_firmware_state")
expect(json[:ticket_identifier].length).to be >= 4
end
it 'deletes' do
sign_in user
# DiagnosticDump.destroy_all
device_config = FactoryBot.create(:diagnostic_dump, device: device)
id = device_config.id
delete :destroy, params: { id: device_config.id }
expect(response.status).to be(200)
expect(DiagnosticDump.exists?(id)).to be false
end
end

View File

@ -20,16 +20,22 @@ describe Api::LogsController do
it "creates one log (legacy format)" do
sign_in user
before_count = Log.count
post :create,
body: { meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["toast"],
message: "Hello, world!"
}.to_json,
params: {format: :json}
now = DateTime.now - 37.3.hours
created_at = now.utc.to_i
post :create, body: {
created_at: created_at,
meta: { x: 1,
y: 2,
z: 3,
type: "info" },
channels: ["toast"],
message: "Hello, world!" }.to_json,
params: { format: :json }
expect(response.status).to eq(200)
expect(Log.count).to be > before_count
expect(Log.last.message).to eq("Hello, world!")
expect(Log.last.device).to eq(user.device)
expect(Log.last.created_at.to_time.to_s).to eq(now.to_time.to_s)
end
it "creates one log" do
@ -81,7 +87,6 @@ describe Api::LogsController do
end
it "Runs compaction when the logs pile up" do
LogDispatch.destroy_all
Log.destroy_all
100.times { Log.create!(device: user.device) }
sign_in user
@ -101,14 +106,12 @@ describe Api::LogsController do
end
it "delivers emails for logs marked as `email`" do
LogDispatch.destroy_all
log = logs.first
LogDispatch.create!(log: log, device: log.device)
b4 = LogDispatch.where(sent_at: nil).count
log = Log.create!(device: user.device)
b4 = Log.where(sent_at: nil).count
ldm = LogDeliveryMailer.new
allow(ldm).to receive(:mail)
ldm.log_digest(log.device)
expect(LogDispatch.where(sent_at: nil).count).to be < b4
expect(Log.where(sent_at: nil).count).to be < b4
end
it "delivers emails for logs marked as `fatal_email`" do

View File

@ -0,0 +1,121 @@
require "spec_helper"
describe Api::RmqUtilsController do
include Devise::Test::ControllerHelpers
let :credentials do
pass = "password123"
user = FactoryBot.create(:user,
password: pass,
password_confirmation: pass)
token = Auth::CreateToken
.run!(email: user.email,
password: pass,
fbos_version: Gem::Version.new("99.99.99"))[:token].encoded
{ username: "device_#{user.device.id}",
password: token }
end
it "allows admins to do anything" do
Api::RmqUtilsController::ALL.map do |action|
post action, params: { username: "admin",
password: ENV.fetch("ADMIN_PASSWORD") }
expect(response.status).to eq(200)
expect(response.body).to include("allow")
end
end
it "allows access to ones own topic" do
p = credentials.merge(routing_key: "bot.#{credentials[:username]}.logs")
post :topic, params: p
expect(response.body).to include("allow")
expect(response.status).to eq(200)
end
it "denies invalid topics" do
post :topic, params: credentials.merge(routing_key: "*")
expect(response.body).to include("malformed topic")
expect(response.status).to eq(422)
end
it "denies viewing other people's topics" do
p = credentials.merge(routing_key: "bot.device_0.from_device")
post :topic, params: p
expect(response.body).to include("deny")
expect(response.status).to eq(403)
end
it "always denies guest users" do
no_no_no = \
{ username: "guest", # RabbitMQ Default user.
password: "guest" } # RabbitMQ Default user.
post :user, params: no_no_no
expect(response.body).to include("deny")
expect(response.status).to eq(403)
end
it "`allow`s admin users when ADMIN_PASSWORD is provided" do
admin_params = { username: "admin",
password: ENV.fetch("ADMIN_PASSWORD") }
post :user, params: admin_params
expect(response.body).to include("allow")
expect(response.status).to eq(200)
end
it "denies admin users when ADMIN_PASSWORD is wrong" do
admin_params = { username: "admin",
password: ENV.fetch("ADMIN_PASSWORD").reverse + "X" }
post :user, params: admin_params
expect(response.body).to include("deny")
expect(response.status).to eq(403)
end
it "`allow`s end users and farmbots when JWT is provided" do
post :user, params: credentials
expect(response.body).to include("allow")
expect(response.status).to eq(200)
end
it "`deny`s end users and farmbots when JWT is provided" do
credentials[:password] = credentials[:password].reverse + "X"
post :user, params: credentials
expect(response.status).to eq(401)
expect(json[:error]).to include("failed to authenticate")
end
it "`deny`s users who try spoofing usernames" do
credentials[:username] = "device_0"
post :user, params: credentials
expect(response.status).to eq(403)
expect(response.body).to include("deny")
end
it "validates vHost" do
vhost = Api::RmqUtilsController::VHOST
post :vhost, params: credentials.merge(vhost: vhost)
expect(response.status).to eq(200)
expect(response.body).to include("allow")
end
it "invalidates vHost" do
vhost = Api::RmqUtilsController::VHOST + "NO"
post :vhost, params: credentials.merge(vhost: vhost)
expect(response.status).to eq(403)
expect(response.body).to include("deny")
end
it "allows RMQ resource usage" do
post :resource, params: credentials.merge({
resource: Api::RmqUtilsController::RESOURCES.sample,
permission: Api::RmqUtilsController::PERMISSIONS.sample,
})
expect(response.status).to eq(200)
expect(response.body).to include("allow")
end
it "denies RMQ resource usage" do
post :resource, params: credentials.merge({ resource: "something_else",
permission: "something_else" })
expect(response.status).to eq(403)
expect(response.body).to include("deny")
end
end

View File

@ -14,14 +14,15 @@ describe Api::SensorReadingsController do
body: { pin: 13, value: 128, x: nil, y: 1, z: 2 }.to_json,
params: { format: :json }
expect(response.status).to eq(200)
expect(json[:id]).to be_kind_of(Integer)
expect(json[:value]).to eq(128)
expect(json[:device_id]).to eq(nil) # Use the serializer, not as_json.
expect(json[:x]).to eq(nil)
expect(json[:y]).to eq(1)
expect(json[:z]).to eq(2)
expect(json[:pin]).to eq(13)
expect(response.status).to eq(200)
expect(json[:id]).to be_kind_of(Integer)
expect(json[:created_at]).to be_kind_of(String)
expect(json[:value]).to eq(128)
expect(json[:device_id]).to eq(nil) # Use the serializer, not as_json.
expect(json[:x]).to eq(nil)
expect(json[:y]).to eq(1)
expect(json[:z]).to eq(2)
expect(json[:pin]).to eq(13)
expect(before < SensorReading.count).to be_truthy
end

View File

@ -22,6 +22,7 @@ describe Api::UsersController do
.to eq(UserSerializer.new(user).as_json.except(*time_stamps))
expect(subject.default_serializer_options[:root]).to be false
expect(subject.default_serializer_options[:user]).to eq(user)
expect(subject.current_device_id).to eq("device_#{user.device.id}")
end
it 'errors if you try to delete with the wrong password' do

View File

@ -0,0 +1,12 @@
FactoryBot.define do
factory :diagnostic_dump do
device
fbos_version "123_fbos_version"
fbos_commit "123_fbos_commit"
firmware_commit "123_firmware_commit"
network_interface "123_network_interface"
fbos_dmesg_dump "123_fbos_dmesg_dump"
firmware_state "123_firmware_state"
ticket_identifier { rand(36**5).to_s(36) }
end
end

View File

@ -1,13 +1,13 @@
require "spec_helper"
describe LogDeliveryMailer, type: :mailer do
let(:device) { FactoryBot.create(:device) }
let!(:log_dispatches) { FactoryBot.create(:log_dispatch, device: device) }
let(:device) { FactoryBot.create(:device) }
let!(:logs) { FactoryBot.create(:log, device: device) }
it "throttles excess requests" do
LogDispatch.max_per_hour = -1 # Throttle it all
Log.max_per_hour = -1 # Throttle it all
x = LogDeliveryMailer.log_digest(device)
expect { x.deliver_now }.to raise_error LogDispatch::RateLimitError
LogDispatch.max_per_hour = 20
expect { x.deliver_now }.to raise_error Log::RateLimitError
Log.max_per_hour = 20
end
end

View File

@ -4,10 +4,8 @@ class FatalErrorPreview < ActionMailer::Preview
device = Device.last
log = Logs::Create.run!({
device: device,
message: "Please login to the web application to "\
"Unlock your device once you've verified"\
" there are no issues with your hardware"\
" and software configuration.",
message: "루비 온 레일즈(Ruby on Rails)는 루비로 작성된 MVC 패턴을 이용하는 오픈" +
" 소스 웹 프레임워크이다.",
channels: ["fatal_email"],
meta: {
type: "error",

View File

@ -1,6 +1,6 @@
require 'spec_helper'
describe LogDispatch do
describe Log do
class FakeLogDeliveryMailer
attr_accessor :calls
@ -23,7 +23,7 @@ describe LogDispatch do
end
it "has a default wait time for batching" do
wt = LogDispatch.digest_wait_time
wt = Log.digest_wait_time
expect(wt).to be_kind_of(Hash)
expect(wt[:wait]).to eq(30.seconds)
end
@ -31,7 +31,7 @@ describe LogDispatch do
it "sends routine emails" do
fdm = FakeLogDeliveryMailer.new
expect(fdm.calls).to eq(0)
LogDispatch.send_routine_emails(log, log.device, fdm)
Log.send_routine_emails(log, log.device, fdm)
expect(fdm.calls).to eq(1)
end
end

View File

@ -1,6 +1,12 @@
describe User do
describe '#new' do
it 'Creates a new user' do
it "lazily instantiates an admin user" do
admin = User.admin_user
expect(admin).to be_kind_of(User)
expect(admin.valid_password?(ENV["ADMIN_PASSWORD"])).to eq(true)
end
describe "#new" do
it "Creates a new user" do
expect(User.new).to be_kind_of(User)
end
end
@ -11,15 +17,15 @@ describe User do
const_reassign(User, :SKIP_EMAIL_VALIDATION, original)
end
describe 'SKIP_EMAIL_VALIDATION' do
describe "SKIP_EMAIL_VALIDATION" do
let (:user) { FactoryBot.create(:user, confirmed_at: nil) }
it 'considers al users verified when set to `true`' do
it "considers al users verified when set to `true`" do
const_reassign(User, :SKIP_EMAIL_VALIDATION, true)
expect(user.verified?).to be(true)
end
it 'does not skip when false' do
it "does not skip when false" do
const_reassign(User, :SKIP_EMAIL_VALIDATION, false)
expect(user.verified?).to be(false)
end

View File

@ -7,6 +7,7 @@ export const panelState = (): ControlPanelState => {
encoders_and_endstops: false,
danger_zone: false,
power_and_reset: false,
pin_guard: false
pin_guard: false,
diagnostic_dumps: false
};
};

View File

@ -1,23 +1,28 @@
import { Dictionary, FarmwareManifest } from "farmbot";
import { Farmwares } from "../farmware/interfaces";
import { FarmwareManifest } from "farmbot";
export function fakeFarmwares(): Dictionary<FarmwareManifest | undefined> {
export function fakeFarmware(): FarmwareManifest {
return {
"farmware_0": {
name: "My Farmware",
uuid: "farmware_0",
executable: "forth",
args: ["my_farmware.fth"],
url: "https://",
path: "my_farmware",
config: [{ name: "config_1", label: "Config 1", value: "4" }],
meta: {
min_os_version_major: "3",
description: "Does things.",
language: "forth",
version: "0.0.0",
author: "me",
zip: "https://"
}
name: "My Fake Farmware",
uuid: "farmware_0",
executable: "forth",
args: ["my_farmware.fth"],
url: "https://",
path: "my_farmware",
config: [{ name: "config_1", label: "Config 1", value: "4" }],
meta: {
min_os_version_major: "3",
description: "Does things.",
language: "forth",
version: "0.0.0",
author: "me",
zip: "https://"
}
};
}
export function fakeFarmwares(): Farmwares {
return {
"farmware_0": fakeFarmware()
};
}

View File

@ -10,6 +10,7 @@ export let bot: Everything["bot"] = {
"danger_zone": false,
"power_and_reset": false,
"pin_guard": false,
"diagnostic_dumps": false
},
"hardware": {
"gpio_registry": {},

View File

@ -8,7 +8,8 @@ import {
TaggedSensor,
TaggedFirmwareConfig,
TaggedPinBinding,
TaggedLog
TaggedLog,
TaggedDiagnosticDump
} from "../../resources/tagged_resources";
import { ExecutableType } from "../../farm_designer/interfaces";
import { fakeResource } from "../fake_resource";
@ -117,6 +118,23 @@ export function fakePlant(): TaggedPlantPointer {
});
}
export function fakeDiagnosticDump(): TaggedDiagnosticDump {
const string = "----PLACEHOLDER DIAG STUFF ---";
return fakeResource("DiagnosticDump", {
id: idCounter++,
device_id: 123,
ticket_identifier: string,
fbos_commit: string,
fbos_version: string,
firmware_commit: string,
firmware_state: string,
network_interface: string,
fbos_dmesg_dump: string,
created_at: string,
updated_at: string,
});
}
export function fakePoint(): TaggedGenericPointer {
return fakeResource("Point", {
id: idCounter++,

View File

@ -1,6 +1,33 @@
import { ReactWrapper } from "enzyme";
import { ReactWrapper, ShallowWrapper } from "enzyme";
import { range } from "lodash";
// tslint:disable-next-line:no-any
export function getProp(i: ReactWrapper<any, {}>, key: string): any {
return i.props()[key];
}
/** Simulate a click and check button text for a button in a wrapper. */
export function clickButton(
wrapper: ReactWrapper | ShallowWrapper,
position: number,
text: string,
options?: { partial_match?: boolean, button_tag?: string }) {
const btnTag = options && options.button_tag ? options.button_tag : "button";
const button = wrapper.find(btnTag).at(position);
const expectedText = text.toLowerCase();
const actualText = button.text().toLowerCase();
options && options.partial_match
? expect(actualText).toContain(expectedText)
: expect(actualText).toEqual(expectedText);
button.simulate("click");
}
/** Like `wrapper.text()`, but only includes buttons. */
export function allButtonText(wrapper: ReactWrapper | ShallowWrapper): string {
const buttons = wrapper.find("button");
const btnCount = buttons.length;
const btnPositions = range(btnCount);
const btnTextArray = btnPositions.map(position =>
wrapper.find("button").at(position).text());
return btnTextArray.join("");
}

View File

@ -1,7 +0,0 @@
import * as React from "react";
export class Wrapper extends React.Component<{}, {}> {
render() {
return <div> {this.props.children} </div>;
}
}

View File

@ -3,14 +3,13 @@ jest.mock("fastclick", () => ({
}));
let mockAuth: AuthState | undefined = undefined;
const mockClear = jest.fn();
jest.mock("../session", () => ({
Session: {
fetchStoredToken: jest.fn(() => mockAuth),
deprecatedGetNum: () => undefined,
deprecatedGetBool: () => undefined,
getAll: () => undefined,
clear: mockClear
clear: jest.fn()
}
}));
@ -27,6 +26,7 @@ import { RootComponent } from "../routes";
import { store } from "../redux/store";
import { AuthState } from "../auth/interfaces";
import { auth } from "../__test_support__/fake_state/token";
import { Session } from "../session";
describe("<RootComponent />", () => {
beforeEach(function () {
@ -37,13 +37,13 @@ describe("<RootComponent />", () => {
mockAuth = undefined;
mockPathname = "/app/account";
shallow(<RootComponent store={store} />);
expect(mockClear).toHaveBeenCalled();
expect(Session.clear).toHaveBeenCalled();
});
it("authorized", () => {
mockAuth = auth;
mockPathname = "/app/account";
shallow(<RootComponent store={store} />);
expect(mockClear).not.toHaveBeenCalled();
expect(Session.clear).not.toHaveBeenCalled();
});
});

View File

@ -10,12 +10,22 @@ jest.mock("axios",
import { API } from "../../api";
import { Content } from "../../constants";
import { requestAccountExport } from "../request_account_export";
import { requestAccountExport, generateFilename } from "../request_account_export";
import { success } from "farmbot-toastr";
import axios from "axios";
import { fakeDevice } from "../../__test_support__/resource_index_builder";
API.setBaseUrl("http://www.foo.bar");
describe("generateFilename", () => {
it("generates a filename", () => {
const device = fakeDevice().body;
device.name = "FOO";
device.id = 123;
const result = generateFilename({ device });
expect(result).toEqual("export_foo_123.json");
});
});
describe("requestAccountExport", () => {
it("pops toast on completion (when API has email support)", async () => {
await requestAccountExport();

View File

@ -1,5 +1,5 @@
import * as React from "react";
import { ChangePassword } from "../components/index";
import { ChangePassword, ChangePWState } from "../components/index";
import { mount } from "enzyme";
import { getProp } from "../../__test_support__/helpers";
import { SpecialStatus } from "../../resources/tagged_resources";
@ -13,7 +13,7 @@ describe("<ChangePassword/>", function () {
});
function testCase() {
const el = mount(<ChangePassword />);
const el = mount<{}, ChangePWState>(<ChangePassword />);
return {
el,
instance(): ChangePassword { return el.instance() as ChangePassword; }
@ -27,11 +27,11 @@ describe("<ChangePassword/>", function () {
form: { ...instance().state.form, password: "X" }
});
el.update();
expect(getProp(el.find("input").first(), "value")).toEqual("X");
expect(getProp(el.find("input").first(), "defaultValue")).toEqual("X");
expect(instance().state.status).toBe(SpecialStatus.DIRTY);
instance().maybeClearForm();
expect(instance().state.status).toBe(SpecialStatus.DIRTY);
expect(getProp(el.find("input").first(), "value")).toEqual("X");
expect(getProp(el.find("input").first(), "defaultValue")).toEqual("X");
});
it("it does fire maybeClearForm() when form is empty.", () => {

View File

@ -21,7 +21,7 @@ interface PasswordForm {
password: string;
}
interface ChangePWState { status: SpecialStatus; form: PasswordForm }
export interface ChangePWState { status: SpecialStatus; form: PasswordForm }
const EMPTY_FORM =
({ new_password: "", new_password_confirmation: "", password: "" });

View File

@ -8,13 +8,14 @@ import { DeviceAccountSettings } from "../devices/interfaces";
interface DataDumpExport { device?: DeviceAccountSettings; }
type Response = AxiosResponse<DataDumpExport | undefined>;
function generateFilename({ device }: DataDumpExport): string {
const name = (device && device.name + "_" + device.id) || "farmbot";
export function generateFilename({ device }: DataDumpExport): string {
let name: string;
name = device ? (device.name + "_" + device.id) : "farmbot";
return `export_${name}.json`.toLowerCase();
}
// Thanks, @KOL - https://stackoverflow.com/a/19328891/1064917
function handleNow(data: DataDumpExport) {
export function jsonDownload(data: object, fname = generateFilename(data)) {
// When email is not available on the API (self hosted).
// Will synchronously load backup over the wire (slow)
const a = document.createElement("a");
@ -24,7 +25,7 @@ function handleNow(data: DataDumpExport) {
blob = new Blob([json], { type: "octet/stream" }),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = generateFilename(data);
a.download = fname;
a.click();
window.URL.revokeObjectURL(url);
return a;
@ -32,7 +33,7 @@ function handleNow(data: DataDumpExport) {
const ok = (resp: Response) => {
const { data } = resp;
return data ? handleNow(data) : success(t(Content.EXPORT_SENT));
return data ? jsonDownload(data) : success(t(Content.EXPORT_SENT));
};
export const requestAccountExport =

View File

@ -0,0 +1,29 @@
import { API } from "../api";
describe("API", () => {
type L = typeof location;
const fakeLocation = (input: Partial<L>) => input as L;
it("requires initialization", () => {
expect(() => API.current).toThrow();
const BASE = "http://localhost:3000";
API.setBaseUrl(BASE);
[
[API.current.pointSearchPath, BASE + "/api/points/search"],
[API.current.sensorReadingPath, BASE + "/api/sensor_readings"],
[API.current.deviceConfigPath, BASE + "/api/device_configs"],
[API.current.plantTemplatePath, BASE + "/api/plant_templates"],
[API.current.diagnosticDumpsPath, BASE + "/api/diagnostic_dumps"],
[API.current.farmwareInstallationPath, BASE + "/api/farmware_installations"],
].map(x => expect(x[0]).toEqual(x[1]));
});
it("infers the correct port", () => {
const xmp: [string, L][] = [
["3000", fakeLocation({ port: "3808" })],
["1234", fakeLocation({ port: "1234" })],
["80", fakeLocation({ port: undefined })],
["443", fakeLocation({ port: undefined, origin: "https://x.y.z" })],
];
xmp.map(x => expect(API.inferPort(x[1])).toEqual(x[0]));
});
});

View File

@ -0,0 +1,101 @@
const mockResource: { kind: string, body: { id: number | undefined } }
= { kind: "Regimen", body: { id: 1 } };
jest.mock("../../resources/reducer", () => ({
findByUuid: () => (mockResource)
}));
jest.mock("../../resources/actions", () => ({
destroyOK: jest.fn(),
destroyNO: jest.fn()
}));
jest.mock("../maybe_start_tracking", () => ({
maybeStartTracking: jest.fn()
}));
let mockDelete: Promise<{}> = Promise.resolve({});
jest.mock("axios", () => ({
default: {
delete: jest.fn(() => mockDelete)
}
}));
import { destroy } from "../crud";
import { API } from "../api";
import axios from "axios";
import { destroyOK, destroyNO } from "../../resources/actions";
describe("destroy", () => {
beforeEach(() => {
jest.clearAllMocks();
mockResource.body.id = 1;
mockResource.kind = "Regimen";
});
API.setBaseUrl("http://localhost:3000");
// tslint:disable-next-line:no-any
const fakeGetState = () => ({ resources: { index: {} } } as any);
const fakeDestroy = () => destroy("fakeResource")(jest.fn(), fakeGetState);
const expectDestroyed = () => {
const kind = mockResource.kind.toLowerCase() + "s";
expect(axios.delete)
.toHaveBeenCalledWith(`http://localhost:3000/api/${kind}/1`);
expect(destroyOK).toHaveBeenCalledWith(mockResource);
};
const expectNotDestroyed = () => {
expect(axios.delete).not.toHaveBeenCalled();
};
it("not confirmed", () => {
expect(fakeDestroy()).rejects.toEqual("User pressed cancel");
expectNotDestroyed();
});
it("id: 0", () => {
mockResource.body.id = 0;
window.confirm = () => true;
expect(fakeDestroy()).resolves.toEqual("");
expect(destroyOK).toHaveBeenCalledWith(mockResource);
});
it("id: undefined", () => {
mockResource.body.id = undefined;
window.confirm = () => true;
expect(fakeDestroy()).resolves.toEqual("");
expect(destroyOK).toHaveBeenCalledWith(mockResource);
});
it("confirmed", async () => {
window.confirm = () => true;
await expect(fakeDestroy()).resolves.toEqual(undefined);
expectDestroyed();
});
it("confirmation overridden", async () => {
window.confirm = () => false;
const forceDestroy = () =>
destroy("fakeResource", true)(jest.fn(), fakeGetState);
await expect(forceDestroy()).resolves.toEqual(undefined);
expectDestroyed();
});
it("confirmation not required", async () => {
mockResource.kind = "Sensor";
window.confirm = () => false;
await expect(fakeDestroy()).resolves.toEqual(undefined);
expectDestroyed();
});
it("rejected", async () => {
window.confirm = () => true;
mockDelete = Promise.reject("error");
await expect(fakeDestroy()).rejects.toEqual("error");
expect(destroyNO).toHaveBeenCalledWith({
err: "error",
statusBeforeError: undefined,
uuid: "fakeResource"
});
});
});

View File

@ -17,7 +17,7 @@ interface UrlInfo {
export class API {
/** Guesses the most appropriate API port based on a number of environment
* factors such as hostname and protocol (HTTP vs. HTTPS). */
static inferPort(): string {
static inferPort(location = window.location): string {
// ATTEMPT 1: Most devs running a webpack server on localhost
// run the API on port 3000.
@ -115,7 +115,7 @@ export class API {
/** /api/points/ */
get pointsPath() { return `${this.baseUrl}/api/points/`; }
/** /api/points/search */
get pointSearchPath() { return `${this.pointsPath}/search/`; }
get pointSearchPath() { return `${this.pointsPath}search`; }
/** Rather than returning ALL logs, returns a filtered subset.
* /api/logs/search */
get filteredLogsPath() { return `${this.baseUrl}/api/logs/search`; }
@ -145,6 +145,8 @@ export class API {
get exportDataPath() { return `${this.baseUrl}/api/export_data`; }
/** /api/plant_templates/:id */
get plantTemplatePath() { return `${this.baseUrl}/api/plant_templates`; }
/** /api/diagnostic_dumps/:id */
get diagnosticDumpsPath() { return `${this.baseUrl}/api/diagnostic_dumps`; }
/** /api/farmware_installations/:id */
get farmwareInstallationPath() {
return `${this.baseUrl}/api/farmware_installations`;

View File

@ -3,7 +3,6 @@ import { t } from "i18next";
import { connect } from "react-redux";
import * as _ from "lodash";
import { init, error } from "farmbot-toastr";
import { NavBar } from "./nav";
import { Everything } from "./interfaces";
import { LoadingPlant } from "./loading_plant";

View File

@ -149,6 +149,12 @@ export namespace ToolTips {
export const FARMWARE =
trim(`Manage Farmware (plugins).`);
export const FARMWARE_LIST =
trim(`View, select, and install new Farmware.`);
export const FARMWARE_INFO =
trim(`Farmware (plugin) details and management.`);
export const PHOTOS =
trim(`Take and view photos with your FarmBot's camera.`);
@ -522,6 +528,17 @@ export namespace Content {
export const SET_TIMEZONE_BODY =
trim(`Set device timezone here.`);
// Farmware
export const NO_IMAGES_YET =
trim(`You haven't yet taken any photos with your FarmBot.
Once you do, they will show up here.`);
export const PROCESSING_PHOTO =
trim(`Processing now. Results usually available in one minute.`);
export const NOT_AVAILABLE_WHEN_OFFLINE =
trim(`Not available when device is offline.`);
}
export enum Actions {
@ -591,6 +608,7 @@ export enum Actions {
SELECT_SEQUENCE = "SELECT_SEQUENCE",
// Farmware
SELECT_FARMWARE = "SELECT_FARMWARE",
SELECT_IMAGE = "SELECT_IMAGE",
FETCH_FIRST_PARTY_FARMWARE_NAMES_OK = "FETCH_FIRST_PARTY_FARMWARE_NAMES_OK",

View File

@ -3,6 +3,7 @@ import { mount } from "enzyme";
import { AxisInputBoxGroup } from "../axis_input_box_group";
import { BotPosition } from "../../devices/interfaces";
import { AxisInputBoxGroupProps } from "../interfaces";
import { clickButton } from "../../__test_support__/helpers";
describe("<AxisInputBoxGroup />", () => {
beforeEach(function () {
@ -45,9 +46,7 @@ describe("<AxisInputBoxGroup />", () => {
props.position = coordinates.position;
const wrapper = mount(<AxisInputBoxGroup {...props} />);
wrapper.setState(coordinates.inputs);
const buttons = wrapper.find("button");
expect(buttons.text().toLowerCase()).toEqual("go");
buttons.simulate("click");
clickButton(wrapper, 0, "go");
expect(props.onCommit).toHaveBeenCalledWith(coordinates.expected);
});
}

View File

@ -5,8 +5,8 @@ const mockDevice = {
jest.mock("../../device", () => ({
getDevice: () => (mockDevice)
}));
const mockOk = jest.fn();
jest.mock("farmbot-toastr", () => ({ success: mockOk }));
jest.mock("farmbot-toastr", () => ({ success: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";

View File

@ -8,8 +8,8 @@ const mockDevice = {
jest.mock("../../device", () => ({
getDevice: () => (mockDevice)
}));
const mockOk = jest.fn();
jest.mock("farmbot-toastr", () => ({ success: mockOk }));
jest.mock("farmbot-toastr", () => ({ success: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";

View File

@ -21,6 +21,7 @@ import { toggleWebAppBool } from "../../config_storage/actions";
import { Dictionary } from "farmbot";
import { BooleanSetting } from "../../session_keys";
import { Actions } from "../../constants";
import { clickButton } from "../../__test_support__/helpers";
describe("<Move />", () => {
beforeEach(function () {
@ -78,9 +79,7 @@ describe("<Move />", () => {
it("changes step size", () => {
const p = fakeProps();
const wrapper = mount(<Move {...p} />);
const btn = wrapper.find("button").first();
expect(btn.text()).toEqual("1");
btn.simulate("click");
clickButton(wrapper, 0, "1");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.CHANGE_STEP_SIZE,
payload: 1

View File

@ -1,6 +1,5 @@
const mockError = jest.fn();
jest.mock("farmbot-toastr", () => ({
error: mockError
error: jest.fn()
}));
import * as React from "react";
@ -9,6 +8,9 @@ import { Peripherals } from "../index";
import { bot } from "../../../__test_support__/fake_state/bot";
import { PeripheralsProps } from "../../../devices/interfaces";
import { fakePeripheral } from "../../../__test_support__/fake_state/resources";
import { clickButton } from "../../../__test_support__/helpers";
import { SpecialStatus } from "../../../resources/tagged_resources";
import { error } from "farmbot-toastr";
describe("<Peripherals />", () => {
beforeEach(function () {
@ -25,7 +27,7 @@ describe("<Peripherals />", () => {
}
it("renders", () => {
const wrapper = mount(<Peripherals {...fakeProps() } />);
const wrapper = mount(<Peripherals {...fakeProps()} />);
["Peripherals", "Edit", "Save", "Fake Pin", "1"].map(string =>
expect(wrapper.text()).toContain(string));
const saveButton = wrapper.find("button").at(1);
@ -34,22 +36,19 @@ describe("<Peripherals />", () => {
});
it("isEditing", () => {
const wrapper = mount(<Peripherals {...fakeProps() } />);
const wrapper = mount(<Peripherals {...fakeProps()} />);
expect(wrapper.state().isEditing).toBeFalsy();
const edit = wrapper.find("button").at(0);
expect(edit.text()).toEqual("Edit");
edit.simulate("click");
clickButton(wrapper, 0, "edit");
expect(wrapper.state().isEditing).toBeTruthy();
});
function attemptSave(num: number, error: string) {
function attemptSave(num: number, errorString: string) {
const p = fakeProps();
p.peripherals[0].body.pin = num;
const wrapper = mount(<Peripherals {...p } />);
const save = wrapper.find("button").at(1);
expect(save.text()).toContain("Save");
save.simulate("click");
expect(mockError).toHaveBeenLastCalledWith(error);
p.peripherals[0].specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<Peripherals {...p} />);
clickButton(wrapper, 1, "save", { partial_match: true });
expect(error).toHaveBeenLastCalledWith(errorString);
}
it("save attempt: pin number too small", () => {
@ -63,30 +62,25 @@ describe("<Peripherals />", () => {
it("saves", () => {
const p = fakeProps();
p.peripherals[0].body.pin = 1;
const wrapper = mount(<Peripherals {...p } />);
const save = wrapper.find("button").at(1);
expect(save.text()).toContain("Save");
save.simulate("click");
p.peripherals[0].specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<Peripherals {...p} />);
clickButton(wrapper, 1, "save", { partial_match: true });
expect(p.dispatch).toHaveBeenCalled();
});
it("adds empty peripheral", () => {
const p = fakeProps();
const wrapper = mount(<Peripherals {...p } />);
const wrapper = mount(<Peripherals {...p} />);
wrapper.setState({ isEditing: true });
const add = wrapper.find("button").at(2);
expect(add.text()).toEqual("");
add.simulate("click");
clickButton(wrapper, 2, "");
expect(p.dispatch).toHaveBeenCalled();
});
it("adds farmduino peripherals", () => {
const p = fakeProps();
const wrapper = mount(<Peripherals {...p } />);
const wrapper = mount(<Peripherals {...p} />);
wrapper.setState({ isEditing: true });
const add = wrapper.find("button").at(3);
expect(add.text()).toEqual("Farmduino");
add.simulate("click");
clickButton(wrapper, 3, "farmduino");
expect(p.dispatch).toHaveBeenCalledTimes(5);
});
});

View File

@ -1,6 +1,5 @@
const mockError = jest.fn();
jest.mock("farmbot-toastr", () => ({
error: mockError
error: jest.fn()
}));
import * as React from "react";
@ -9,6 +8,9 @@ import { Sensors } from "../index";
import { bot } from "../../../__test_support__/fake_state/bot";
import { SensorsProps } from "../../../devices/interfaces";
import { fakeSensor } from "../../../__test_support__/fake_state/resources";
import { error } from "farmbot-toastr";
import { clickButton } from "../../../__test_support__/helpers";
import { SpecialStatus } from "../../../resources/tagged_resources";
describe("<Sensors />", () => {
beforeEach(function () {
@ -40,9 +42,7 @@ describe("<Sensors />", () => {
it("isEditing", () => {
const wrapper = mount(<Sensors {...fakeProps()} />);
expect(wrapper.state().isEditing).toBeFalsy();
const edit = wrapper.find("button").at(0);
expect(edit.text()).toEqual("Edit");
edit.simulate("click");
clickButton(wrapper, 0, "edit");
expect(wrapper.state().isEditing).toBeTruthy();
});
@ -50,20 +50,18 @@ describe("<Sensors />", () => {
const p = fakeProps();
p.sensors[0].body.pin = 1;
p.sensors[1].body.pin = 1;
p.sensors[0].specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<Sensors {...p} />);
const save = wrapper.find("button").at(1);
expect(save.text()).toContain("Save");
save.simulate("click");
expect(mockError).toHaveBeenLastCalledWith("Pin numbers must be unique.");
clickButton(wrapper, 1, "save", { partial_match: true });
expect(error).toHaveBeenLastCalledWith("Pin numbers must be unique.");
});
it("saves", () => {
const p = fakeProps();
p.sensors[0].body.pin = 1;
p.sensors[0].specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<Sensors {...p} />);
const save = wrapper.find("button").at(1);
expect(save.text()).toContain("Save");
save.simulate("click");
clickButton(wrapper, 1, "save", { partial_match: true });
expect(p.dispatch).toHaveBeenCalled();
});
@ -71,9 +69,7 @@ describe("<Sensors />", () => {
const p = fakeProps();
const wrapper = mount(<Sensors {...p} />);
wrapper.setState({ isEditing: true });
const add = wrapper.find("button").at(2);
expect(add.text()).toEqual("");
add.simulate("click");
clickButton(wrapper, 2, "");
expect(p.dispatch).toHaveBeenCalled();
});
@ -81,9 +77,7 @@ describe("<Sensors />", () => {
const p = fakeProps();
const wrapper = mount(<Sensors {...p} />);
wrapper.setState({ isEditing: true });
const add = wrapper.find("button").at(3);
expect(add.text()).toEqual("Stock sensors");
add.simulate("click");
clickButton(wrapper, 3, "stock sensors");
expect(p.dispatch).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,27 +1,44 @@
import * as React from "react";
import { fakeWebcamFeed } from "../../../__test_support__/fake_state/resources";
import { shallow } from "enzyme";
import { mount } from "enzyme";
import { props } from "../test_helpers";
import { Edit } from "../edit";
import { SpecialStatus } from "../../../resources/tagged_resources";
import { clickButton } from "../../../__test_support__/helpers";
import { WebcamPanelProps } from "../interfaces";
describe("<Edit/>", () => {
it("renders the list of feeds", () => {
const fakeProps = (): WebcamPanelProps => {
const feed1 = fakeWebcamFeed();
const feed2 = fakeWebcamFeed();
feed1.specialStatus = SpecialStatus.DIRTY;
const p = props([feed1, feed2]);
const el = shallow(<Edit {...p} />);
const inputs = el.html();
expect(inputs).toContain(feed1.body.name);
expect(inputs).toContain(feed1.body.url);
expect(inputs).toContain(feed2.body.name);
expect(inputs).toContain(feed2.body.url);
expect(el.html()).toContain("Save*");
el.find("button").at(1).simulate("click");
expect(p.save).toHaveBeenCalledWith(feed1);
feed1.specialStatus = SpecialStatus.SAVED;
el.update();
expect(el.text()).not.toContain("Save*");
return props([feed1, feed2]);
};
it("renders the list of feeds", () => {
const p = fakeProps();
const wrapper = mount(<Edit {...p} />);
[
p.feeds[0].body.name,
p.feeds[0].body.url,
p.feeds[1].body.name,
p.feeds[1].body.url
].map(text =>
expect(wrapper.html()).toContain(text));
});
it("saves feeds", () => {
const p = fakeProps();
const wrapper = mount(<Edit {...p} />);
clickButton(wrapper, 1, "save*");
expect(p.save).toHaveBeenCalledWith(p.feeds[0]);
});
it("shows feeds as saved", () => {
const p = fakeProps();
p.feeds[0].specialStatus = SpecialStatus.SAVED;
p.feeds[1].specialStatus = SpecialStatus.SAVED;
const wrapper = mount(<Edit {...p} />);
expect(wrapper.find("button").at(1).text()).toEqual("Save");
});
});

View File

@ -8,15 +8,27 @@ import { WebcamPanel, preToggleCleanup } from "../index";
import { fakeWebcamFeed } from "../../../__test_support__/fake_state/resources";
import { destroy, save } from "../../../api/crud";
import { SpecialStatus } from "../../../resources/tagged_resources";
import { clickButton, allButtonText } from "../../../__test_support__/helpers";
describe("<WebcamPanel/>", () => {
it("toggles form states", () => {
it("toggles form state to edit", () => {
const props = { feeds: [], dispatch: jest.fn() };
const el = mount(<WebcamPanel {...props} />);
expect(el.text()).toContain("edit");
el.find("button").first().simulate("click");
el.update();
expect(el.text()).toContain("view");
const wrapper = mount(<WebcamPanel {...props} />);
expect(wrapper.state().activeMenu).toEqual("show");
const text = allButtonText(wrapper);
expect(text.toLowerCase()).not.toContain("view");
clickButton(wrapper, 0, "edit");
expect(wrapper.state().activeMenu).toEqual("edit");
});
it("toggles form state to view", () => {
const props = { feeds: [], dispatch: jest.fn() };
const wrapper = mount(<WebcamPanel {...props} />);
wrapper.setState({ activeMenu: "edit" });
const text = allButtonText(wrapper);
expect(text.toLowerCase()).not.toContain("edit");
clickButton(wrapper, 2, "view");
expect(wrapper.state().activeMenu).toEqual("show");
});
});

View File

@ -589,6 +589,45 @@ ul {
}
}
.farmware {
button {
margin: 0.5rem;
}
}
.farmware-input-panel,
.farmware-info-panel {
label, h4 {
margin-top: 2rem;
}
}
.farmware-list-items {
margin-left: -30px;
margin-right: -20px;
padding: 0.5rem;
padding-left: 1.5rem;
padding-top: 0.75rem;
cursor: pointer;
label {
cursor: pointer;
}
&:hover, &.selected {
background: $medium_light_gray;
p {
font-weight: bold;
}
}
}
.farmware-button,
.farmware-settings-menu {
float: right !important;
position: absolute !important;
right: 3rem;
top: 1rem;
}
.logs {
.row {
@media screen and (max-width: 974px) {
@ -778,3 +817,21 @@ ul {
}
}
}
a.panel-link {
// TODO: Might need to move this to a better place. CC: @gabrielBurnworth
//
// PROBLEM: <a> links in the device panel are invisible.
// SOLUTION: Add {color: $dark_gray}
// PROBLEM 2: This rule probably does not belong at the bottom of
// global.scss
color: $dark_gray;
&:visited {
color: $dark_gray;
};
&:active {
color: $dark_gray;
};
}

View File

@ -11,9 +11,9 @@
.image-flipper-left {
position: absolute;
left: 0;
top: 0;
bottom: 0;
left: -5px;
top: -5px;
bottom: -5px;
background-color: rgba(0, 0, 0, 0.5);
&:hover {
background-color: rgba(0, 0, 0, 0.7);
@ -22,9 +22,9 @@
.image-flipper-right {
position: absolute;
right: 0;
top: 0;
bottom: 0;
right: -5px;
top: -5px;
bottom: -5px;
background-color: rgba(0, 0, 0, 0.5);
&:hover {
background-color: rgba(0, 0, 0, 0.7);
@ -57,8 +57,14 @@
}
}
.photos-widget {
.widget-footer {
.photos {
.photos-footer {
padding-left: 0.5rem;
padding-right: 0.5rem;
position: relative;
left: 2.5rem;
bottom: -1rem;
width: 90%;
display: flex;
justify-content: space-between;
label {

View File

@ -1,3 +1,4 @@
.farmware-input-panel,
.sequence-editor-panel,
.regimen-editor-panel {
height: calc(100vh - 5rem);
@ -26,6 +27,18 @@
}
}
.farmware-input-panel {
padding-left: 0;
padding-right: 0;
h3 {
padding-left: 15px;
}
.title-help-text {
padding-left: 3rem;
}
}
.farmware-input-panel-contents,
.sequence-editor-content,
.regimen-editor-content {
margin-right: -15px;
@ -52,6 +65,15 @@
margin-top: 15px;
}
.farmware-input-panel-contents {
padding: 15px;
width: 100%;
height: calc(100vh - 18rem);
overflow-y: auto;
overflow-x: hidden;
margin-top: 15px;
}
.sequence-steps {
margin-right: 15px;
}
@ -69,6 +91,7 @@
margin-right: -15px;
}
.farmware-info-panel,
.step-button-cluster-panel {
@media screen and (max-width: 974px) {
margin-left: 15px;
@ -76,6 +99,10 @@
}
}
.farmware-info-panel button {
margin-bottom: 3rem;
}
.step-button-cluster {
@media screen and (max-width: 974px) {
margin-left: 0;
@ -83,6 +110,7 @@
}
}
.farmware-list-items,
.sequence-list-items,
.regimen-list {
button {
@ -105,6 +133,7 @@
margin-right: 15px;
}
.farmware-list-panel,
.sequence-list-panel,
.regimen-list-panel {
padding-top: 0.4rem;
@ -116,6 +145,29 @@
}
}
.farmware-list-panel {
margin-bottom: 5rem;
label {
font-weight: bold;
font-size: 1.4rem;
}
p {
font-size: 1.2rem;
}
}
.farmware-list-items {
margin-left: -15px;
}
.farmware-input-panel-contents {
@media screen and (max-width: 974px) {
margin-left: 15px;
margin-right: 15px;
padding-right: 5rem;
}
}
.sequence-list-panel input,
.regimen-list-panel input {
margin-bottom: 1rem;

View File

@ -48,8 +48,22 @@
color: transparent;
transition: all 0.5s ease;
transition-delay: 0.5s;
a {
pointer-events: all;
}
a:link {
font-style: normal;
color: $black;
}
a:hover {
font-weight: 600;
color: $black;
}
a:active {
color: $black;
}
}
.title-help-icon {
margin: 0 1rem;
}
}

View File

@ -13,7 +13,8 @@ const mockDevice = {
home: jest.fn(() => { return Promise.resolve(); }),
sync: jest.fn(() => { return Promise.resolve(); }),
readStatus: jest.fn(() => Promise.resolve()),
updateConfig: jest.fn(() => Promise.resolve())
updateConfig: jest.fn(() => Promise.resolve()),
dumpInfo: jest.fn(() => Promise.resolve()),
};
jest.mock("../../device", () => ({
@ -21,15 +22,12 @@ jest.mock("../../device", () => ({
return mockDevice;
}
}));
const mockOk = jest.fn();
const mockInfo = jest.fn();
const mockError = jest.fn();
const mockWarning = jest.fn();
jest.mock("farmbot-toastr", () => ({
success: mockOk,
info: mockInfo,
error: mockError,
warning: mockWarning,
success: jest.fn(),
info: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
}));
let mockGetRelease: Promise<{}> = Promise.resolve({});
@ -52,6 +50,7 @@ import axios from "axios";
import { SpecialStatus } from "../../resources/tagged_resources";
import { McuParamName } from "farmbot";
import { bot } from "../../__test_support__/fake_state/bot";
import { success, error, warning, info } from "farmbot-toastr";
describe("checkControllerUpdates()", function () {
beforeEach(function () {
@ -61,7 +60,7 @@ describe("checkControllerUpdates()", function () {
it("calls checkUpdates", async () => {
await actions.checkControllerUpdates();
expect(mockDevice.checkUpdates).toHaveBeenCalled();
expect(mockOk).toHaveBeenCalled();
expect(success).toHaveBeenCalled();
});
});
@ -73,7 +72,7 @@ describe("powerOff()", function () {
it("calls powerOff", async () => {
await actions.powerOff();
expect(mockDevice.powerOff).toHaveBeenCalled();
expect(mockOk).toHaveBeenCalled();
expect(success).toHaveBeenCalled();
});
});
@ -103,7 +102,7 @@ describe("reboot()", function () {
it("calls reboot", async () => {
await actions.reboot();
expect(mockDevice.reboot).toHaveBeenCalled();
expect(mockOk).toHaveBeenCalled();
expect(success).toHaveBeenCalled();
});
});
@ -134,7 +133,7 @@ describe("sync()", function () {
actions.sync()(jest.fn(), getState);
expect(mockDevice.sync).not.toHaveBeenCalled();
const expectedMessage = ["FarmBot is not connected.", "Disconnected", "red"];
expect(mockInfo).toBeCalledWith(...expectedMessage);
expect(info).toBeCalledWith(...expectedMessage);
});
});
@ -147,7 +146,7 @@ describe("execSequence()", function () {
const s = fakeSequence().body;
await actions.execSequence(s);
expect(mockDevice.execSequence).toHaveBeenCalledWith(s.id);
expect(mockOk).toHaveBeenCalled();
expect(success).toHaveBeenCalled();
});
it("implodes when executing unsaved sequences", () => {
@ -176,6 +175,13 @@ describe("MCUFactoryReset()", function () {
});
});
describe("requestDiagnostic", () => {
it("requests that FBOS build a diagnostic report", () => {
actions.requestDiagnostic();
expect(mockDevice.dumpInfo).toHaveBeenCalled();
});
});
describe("settingToggle()", () => {
beforeEach(function () {
jest.clearAllMocks();
@ -264,7 +270,7 @@ describe("updateMCU()", () => {
await actions.updateMCU(
"movement_min_spd_x", "100")(jest.fn(), () => state);
expect(mockDevice.updateMcu).not.toHaveBeenCalled();
expect(mockWarning).toHaveBeenCalledWith(
expect(warning).toHaveBeenCalledWith(
"Minimum speed should always be lower than maximum");
});
});
@ -277,7 +283,7 @@ describe("pinToggle()", function () {
it("calls togglePin", async () => {
await actions.pinToggle(5);
expect(mockDevice.togglePin).toHaveBeenCalledWith({ pin_number: 5 });
expect(mockOk).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
});
});
@ -290,7 +296,7 @@ describe("homeAll()", function () {
await actions.homeAll(100);
expect(mockDevice.home)
.toHaveBeenCalledWith({ axis: "all", speed: 100 });
expect(mockOk).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
});
});
@ -346,7 +352,7 @@ describe("fetchReleases()", () => {
const dispatch = jest.fn();
await actions.fetchReleases("url")(dispatch);
expect(axios.get).toHaveBeenCalledWith("url");
expect(mockError).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: { version: "1.0.0", commit: undefined },
type: Actions.FETCH_OS_UPDATE_INFO_OK
@ -360,7 +366,7 @@ describe("fetchReleases()", () => {
const dispatch = jest.fn();
await actions.fetchReleases("url", { beta: true })(dispatch);
expect(axios.get).toHaveBeenCalledWith("url");
expect(mockError).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: { version: "1.0.0-beta", commit: "commit" },
type: Actions.FETCH_BETA_OS_UPDATE_INFO_OK
@ -372,7 +378,7 @@ describe("fetchReleases()", () => {
const dispatch = jest.fn();
await actions.fetchReleases("url")(dispatch);
await expect(axios.get).toHaveBeenCalledWith("url");
expect(mockError).toHaveBeenCalledWith(
expect(error).toHaveBeenCalledWith(
"Could not download FarmBot OS update information.");
expect(dispatch).toHaveBeenCalledWith({
payload: "error",
@ -385,7 +391,7 @@ describe("fetchReleases()", () => {
const dispatch = jest.fn();
await actions.fetchReleases("url", { beta: true })(dispatch);
await expect(axios.get).toHaveBeenCalledWith("url");
expect(mockError).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: "error",
type: "FETCH_BETA_OS_UPDATE_INFO_ERROR"
@ -448,7 +454,7 @@ describe("fetchMinOsFeatureData()", () => {
const dispatch = jest.fn();
await actions.fetchMinOsFeatureData("url")(dispatch);
await expect(axios.get).toHaveBeenCalledWith("url");
expect(mockError).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: "error",
type: "FETCH_MIN_OS_FEATURE_INFO_ERROR"
@ -492,7 +498,7 @@ describe("updateConfig()", () => {
describe("badVersion()", () => {
it("warns of old FBOS version", () => {
actions.badVersion();
expect(mockInfo).toHaveBeenCalledWith(
expect(info).toHaveBeenCalledWith(
expect.stringContaining("old version"), "Please Update", "red");
});
});

View File

@ -45,8 +45,12 @@ describe("botRedcuer", () => {
type: Actions.BULK_TOGGLE_CONTROL_PANEL,
payload: true
});
_.values(_.omit(state.controlPanelState, "power_and_reset"))
.map(value => expect(value).toBeTruthy());
const bulkToggable =
_.omit(state.controlPanelState, "power_and_reset", "diagnostic_dumps");
_.values(bulkToggable).map(value => {
expect(value).toBeTruthy();
});
});
it("fetches OS update info", () => {

View File

@ -140,6 +140,11 @@ export function execSequence(sequence: Sequence) {
}
}
export function requestDiagnostic() {
const noun = "Diagnostic Request";
return getDevice().dumpInfo().then(commandOK(noun), commandErr(noun));
}
export let saveAccountChanges: Thunk = function (_dispatch, getState) {
return save(getDeviceAccountSettings(getState().resources.index));
};

View File

@ -0,0 +1,27 @@
jest.mock("../../../account/request_account_export", () => {
return { jsonDownload: jest.fn() };
});
jest.mock("../../../api/crud", () => {
return { destroy: jest.fn() };
});
import * as React from "react";
import { mount } from "enzyme";
import { DiagnosticDumpRow } from "../diagnostic_dump_row";
import { fakeDiagnosticDump } from "../../../__test_support__/fake_state/resources";
import { jsonDownload } from "../../../account/request_account_export";
import { destroy } from "../../../api/crud";
describe("<DiagnosticDumpRow/>", () => {
it("renders a single diagnostic dump", () => {
const dispatch = jest.fn();
const diag = fakeDiagnosticDump();
diag.body.ticket_identifier = "0000";
const el = mount(<DiagnosticDumpRow dispatch={dispatch} diag={diag} />);
expect(el.text()).toContain("0000");
el.find("a").first().simulate("click");
expect(jsonDownload).toHaveBeenCalledWith(diag.body, "farmbot_diagnostics_0000.json");
el.find("button.red").first().simulate("click");
expect(destroy).toHaveBeenCalledWith(diag.uuid);
});
});

View File

@ -26,6 +26,7 @@ describe("<FarmbotOsSettings/>", () => {
const fakeProps = (): FarmbotOsProps => {
return {
account: fakeResource("Device", { id: 0, name: "", tz_offset_hrs: 0 }),
diagnostics: [],
dispatch: jest.fn(),
bot,
botToMqttLastSeen: "",

View File

@ -6,6 +6,7 @@ import { Actions } from "../../../constants";
import { bot } from "../../../__test_support__/fake_state/bot";
import { panelState } from "../../../__test_support__/control_panel_state";
import { fakeFirmwareConfig } from "../../../__test_support__/fake_state/resources";
import { clickButton } from "../../../__test_support__/helpers";
describe("<HardwareSettings />", () => {
beforeEach(() => {
@ -43,9 +44,9 @@ describe("<HardwareSettings />", () => {
payload: boolean | string) {
const p = fakeProps();
const wrapper = mount(<HardwareSettings {...p} />);
const button = wrapper.find(buttonElement).at(buttonIndex);
expect(button.text().toLowerCase()).toContain(buttonText);
button.simulate("click");
clickButton(wrapper, buttonIndex, buttonText, {
button_tag: buttonElement, partial_match: true
});
expect(p.dispatch).toHaveBeenCalledWith({ payload, type });
}

View File

@ -0,0 +1,32 @@
import * as React from "react";
import { render } from "enzyme";
import { SendDiagnosticReport } from "../send_diagnostic_report";
import { fakeDiagnosticDump } from "../../../__test_support__/fake_state/resources";
describe("<SendDiagnosticReport/>", () => {
it("renders", () => {
const dispatch = jest.fn();
const shouldDisplay = jest.fn(() => true);
const fake = fakeDiagnosticDump();
const el = render(<SendDiagnosticReport
diagnostics={[fake]}
expanded={true}
dispatch={dispatch}
shouldDisplay={shouldDisplay} />);
expect(el.text()).toContain("DIAGNOSTIC CHECK");
expect(shouldDisplay).toHaveBeenCalled();
});
it("doesn't render", () => {
const dispatch = jest.fn();
const shouldDisplay = jest.fn(() => false);
const fake = fakeDiagnosticDump();
const el = render(<SendDiagnosticReport
diagnostics={[fake]}
expanded={true}
dispatch={dispatch}
shouldDisplay={shouldDisplay} />);
expect(el.text()).toEqual("");
expect(shouldDisplay).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Row, Col } from "../../ui";
import { TaggedDiagnosticDump } from "../../resources/tagged_resources";
import { jsonDownload } from "../../account/request_account_export";
import { destroy } from "../../api/crud";
import { ago } from "../connectivity/status_checks";
export interface Props {
diag: TaggedDiagnosticDump;
dispatch: Function;
}
export class DiagnosticDumpRow extends React.Component<Props, {}> {
get ticket() { return this.props.diag.body.ticket_identifier; }
get age() { return ago(this.props.diag.body.created_at); }
destroy = () => this.props.dispatch(destroy(this.props.diag.uuid));
download = (e: React.MouseEvent<{}>) => {
e.preventDefault();
const { body } = this.props.diag;
const { ticket_identifier } = body;
const fileName = `farmbot_diagnostics_${ticket_identifier}.json`;
jsonDownload(body, fileName);
}
render() {
return <Row>
<Col xs={1}>
<span>
<button
className="red fb-button del-button"
onClick={this.destroy}>
<i className="fa fa-times" />
</button>
</span>
</Col>
<Col xs={11}>
<a onClick={this.download} className="panel-link">
Download diagnostic report {this.ticket} (Saved {this.age})
</a>
</Col>
</Row >;
}
}

View File

@ -22,6 +22,8 @@ import { AutoUpdateRow } from "./fbos_settings/auto_update_row";
import { AutoSyncRow } from "./fbos_settings/auto_sync_row";
import { isUndefined } from "lodash";
import { PowerAndReset } from "./fbos_settings/power_and_reset";
import { SendDiagnosticReport } from "./send_diagnostic_report";
import axios from "axios";
export enum ColWidth {
@ -166,6 +168,11 @@ export class FarmbotOsSettings
sourceFbosConfig={sourceFbosConfig}
shouldDisplay={this.props.shouldDisplay}
botOnline={botOnline} />
<SendDiagnosticReport
diagnostics={this.props.diagnostics}
expanded={this.props.bot.controlPanelState.diagnostic_dumps}
shouldDisplay={this.props.shouldDisplay}
dispatch={this.props.dispatch} />
</MustBeOnline>
</WidgetBody>
</form>

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