Merge branch 'staging' into master
commit
88e830cbd1
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -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"
|
||||
|
|
39
Gemfile.lock
39
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
16
README.md
16
README.md
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
class DiagnosticDump < ApplicationRecord
|
||||
belongs_to :device
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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 ||= []
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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"] %>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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: ""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
|
@ -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
|
30
db/schema.rb
30
db/schema.rb
|
@ -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"
|
||||
|
|
25
db/seeds.rb
25
db/seeds.rb
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
|
@ -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 %>">>}
|
||||
]}
|
||||
].
|
|
@ -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))
|
||||
|
||||
|
|
29
package.json
29
package.json
|
@ -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": {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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": {},
|
||||
|
|
|
@ -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++,
|
||||
|
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
export class Wrapper extends React.Component<{}, {}> {
|
||||
render() {
|
||||
return <div> {this.props.children} </div>;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.", () => {
|
||||
|
|
|
@ -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: "" });
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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]));
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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: "",
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 >;
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue