Merge pull request #1171 from FarmBot/staging

v7.2.5 - Happy Hibiscus
This commit is contained in:
Rick Carlino 2019-04-29 12:38:30 -05:00 committed by GitHub
commit d8da8b711e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 1453 additions and 490 deletions

View file

@ -16,7 +16,7 @@ gem "mutations"
gem "paperclip"
gem "pg"
gem "polymorphic_constraints"
# gem "rack-attack" # RC 17 APR 19
gem "rack-attack"
gem "rack-cors"
gem "rails_12factor"
gem "rails"

View file

@ -59,12 +59,12 @@ GEM
arel (9.0.0)
bcrypt (3.1.12)
builder (3.2.3)
bunny (2.14.1)
bunny (2.14.2)
amq-protocol (~> 2.3, >= 2.3.0)
case_transform (0.2)
activesupport
childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11)
childprocess (1.0.1)
rake (< 13.0)
choice (0.2.0)
climate_control (0.2.0)
codecov (0.1.14)
@ -95,11 +95,11 @@ GEM
docile (1.3.1)
erubi (1.8.0)
eventmachine (1.2.7)
excon (0.62.0)
excon (0.64.0)
factory_bot (5.0.2)
activesupport (>= 4.2.0)
factory_bot_rails (5.0.1)
factory_bot (~> 5.0.0)
factory_bot_rails (5.0.2)
factory_bot (~> 5.0.2)
railties (>= 4.2.0)
faker (1.9.3)
i18n (>= 0.7)
@ -107,7 +107,6 @@ GEM
multipart-post (>= 1.2, < 3)
faraday_middleware (0.13.1)
faraday (>= 0.7.4, < 1.0)
ffi (1.10.0)
figaro (1.1.1)
thor (~> 0.14)
fog-core (2.1.0)
@ -126,8 +125,8 @@ GEM
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)
font-awesome-rails (4.7.0.5)
railties (>= 3.2, < 6.1)
foreman (0.85.0)
thor (~> 0.19.1)
formatador (0.2.5)
@ -148,7 +147,7 @@ GEM
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.7)
hashdiff (0.3.8)
hashdiff (0.3.9)
hashie (3.6.0)
httpclient (2.8.3)
i18n (1.6.0)
@ -160,7 +159,7 @@ GEM
addressable (~> 2.3)
letter_opener (1.7.0)
launchy (~> 2.2)
lol_dba (2.1.7)
lol_dba (2.1.8)
actionpack (>= 3.0, < 6.0)
activerecord (>= 3.0, < 6.0)
railties (>= 3.0, < 6.0)
@ -185,10 +184,10 @@ GEM
mutations (0.9.0)
activesupport
nio4r (2.3.1)
nokogiri (1.10.2)
nokogiri (1.10.3)
mini_portile2 (~> 2.4.0)
orm_adapter (0.5.0)
os (1.0.0)
os (1.0.1)
paperclip (6.1.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
@ -213,6 +212,8 @@ GEM
hashie (~> 3.6)
multi_json (~> 1.13.1)
rack (2.0.7)
rack-attack (6.0.0)
rack (>= 1.0, < 3)
rack-cors (1.0.3)
rack-test (1.1.0)
rack (>= 1.0, < 3)
@ -270,7 +271,7 @@ GEM
rspec-mocks (~> 3.8.0)
rspec-core (3.8.0)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.2)
rspec-expectations (3.8.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.0)
@ -291,8 +292,8 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
secure_headers (6.1.0)
selenium-webdriver (3.141.0)
childprocess (~> 0.5)
selenium-webdriver (3.142.0)
childprocess (>= 0.5, < 2.0)
rubyzip (~> 1.2, >= 1.2.2)
signet (0.11.0)
addressable (~> 2.3)
@ -369,6 +370,7 @@ DEPENDENCIES
pry
pry-rails
rabbitmq_http_api_client
rack-attack
rack-cors
rails
rails-erd

View file

@ -0,0 +1,17 @@
module Api
class AlertsController < Api::AbstractController
def index
render json: current_device.alerts
end
def destroy
mutate Alerts::Destroy.run(alert: alert)
end
private
def alert
@alert ||= current_device.alerts.find(params[:id])
end
end
end

View file

@ -35,7 +35,7 @@ module Api
end
def seed
Devices::SeedData.delay.run!(params.as_json, device: current_device)
Devices::CreateSeedData.delay.run!(params.as_json, device: current_device)
render json: { done: "Loading resources now." }
end

View file

@ -1,17 +0,0 @@
module Api
class EnigmasController < Api::AbstractController
def index
render json: current_device.enigmas
end
def destroy
mutate Enigmas::Destroy.run(enigma: enigma)
end
private
def enigma
@enigma ||= current_device.enigmas.find(params[:id])
end
end
end

View file

@ -0,0 +1,16 @@
module Api
class GlobalBulletinsController < Api::AbstractController
skip_before_action :authenticate_user!
skip_before_action :check_fbos_version
def show
render json: search_results
end
private
def search_results
@search_results ||= GlobalBulletin.find_by(slug: params[:id])
end
end
end

View file

@ -3,21 +3,21 @@
# Also handles throttling.
class LogService
T = ThrottlePolicy::TimePeriod
THROTTLE_POLICY = ThrottlePolicy.new T.new(1.minute) => 0.5 * 1_000,
T.new(1.hour) => 0.5 * 10_000,
T.new(1.day) => 0.5 * 100_000
THROTTLE_POLICY = ThrottlePolicy.new T.new(1.minute) => 0.5 * 1_000,
T.new(1.hour) => 0.5 * 10_000,
T.new(1.day) => 0.5 * 100_000
def self.process(delivery_info, payload)
params = { routing_key: delivery_info.routing_key, payload: payload }
data = AmqpLogParser.run!(params)
puts data.payload["message"] if Rails.env.production?
THROTTLE_POLICY.track(data.device_id)
maybe_deliver(data)
m = AmqpLogParser.run!(params)
puts "#{m.device_id}: #{m.payload["message"]}" if Rails.env.production?
THROTTLE_POLICY.track(m.device_id)
maybe_deliver(m)
end
def self.maybe_deliver(data)
violation = THROTTLE_POLICY.is_throttled(data.device_id)
ok = data.valid? && !violation
ok = data.valid? && !violation
data.device.auto_sync_transaction do
ok ? deliver(data) : warn_user(data, violation)

12
app/models/alert.rb Normal file
View file

@ -0,0 +1,12 @@
class Alert < ApplicationRecord
belongs_to :device
PROBLEM_TAGS = [
SEED_DATA = "api.seed_data.missing",
TOUR = "api.tour.not_taken",
USER = "api.user.not_welcomed",
DOCUMENTATION = "api.documentation.unread",
BULLETIN = "api.bulletin.unread"
]
validates_inclusion_of :problem_tag, in: PROBLEM_TAGS
end

View file

@ -20,7 +20,7 @@ module CeleryScriptSettingsBag
"BoxLed3" => BoxLed,
"BoxLed4" => BoxLed }
ALLOWED_AXIS = %w(x y z all)
ALLOWED_CHAGES = %w(add remove update)
ALLOWED_CHANGES = %w(add remove update)
ALLOWED_CHANNEL_NAMES = %w(ticker toast email espeak)
ALLOWED_LHS_STRINGS = [*(0..69)].map { |x| "pin#{x}" }.concat(%w(x y z))
ALLOWED_LHS_TYPES = [String, :named_pin]
@ -94,7 +94,7 @@ module CeleryScriptSettingsBag
ALLOWED_PIN_MODES: [ALLOWED_PIN_MODES, BAD_ALLOWED_PIN_MODES],
AllowedPinTypes: [ALLOWED_PIN_TYPES, BAD_PIN_TYPE],
Color: [Sequence::COLORS, MISC_ENUM_ERR],
DataChangeType: [ALLOWED_CHAGES, MISC_ENUM_ERR],
DataChangeType: [ALLOWED_CHANGES, MISC_ENUM_ERR],
LegalSequenceKind: [ALLOWED_RPC_NODES.sort, MISC_ENUM_ERR],
lhs: [ALLOWED_LHS_STRINGS, BAD_LHS],
PlantStage: [PLANT_STAGES, MISC_ENUM_ERR],
@ -211,7 +211,7 @@ module CeleryScriptSettingsBag
blk: ->(node) do
x = [ALLOWED_LHS_STRINGS, node, BAD_LHS]
# This would never have happened if we hadn't allowed
# heterogenus args :(
# heterogenous args :(
manual_enum(*x) unless node.is_a?(CeleryScript::AstNode)
end,
},

View file

@ -12,7 +12,7 @@ class Device < ApplicationRecord
"Resuming log storage."
CACHE_KEY = "devices.%s"
has_many :enigmas, dependent: :destroy
has_many :alerts, dependent: :destroy
has_many :farmware_envs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :farmware_installations, dependent: :destroy

View file

@ -1,9 +0,0 @@
class Enigma < ApplicationRecord
belongs_to :device
PROBLEM_TAGS = [
SEED_DATA = "api.seed_data.missing",
TOUR = "api.tour.not_taken",
USER = "api.user.not_welcomed",
DOCUMENTATION = "api.documentation.unread"
]
end

View file

@ -7,10 +7,10 @@ class FarmwareEnv < ApplicationRecord
PRIMITIVES_ONLY = "`value` must be a string, number or boolean"
def primitives_only
errors.add(:value, PRIMITIVES_ONLY) unless is_primitve
errors.add(:value, PRIMITIVES_ONLY) unless is_primitive
end
def is_primitve
def is_primitive
[String, Integer, Float, TrueClass, FalseClass].include?(value.class)
end
end

View file

@ -0,0 +1,5 @@
class GlobalBulletin < ActiveRecord::Base
self.inheritance_column = "none"
validates_uniqueness_of :slug
validates_presence_of :content, :slug, :type
end

View file

@ -1,4 +1,14 @@
# A single organism living in the ground.
class Plant < Point
DEFAULT_ICON = "/app-resources/img/icons/generic-plant.svg"
ATTRS = %w(meta
name
openfarm_slug
plant_stage
planted_at
pointer_type
radius
x
y
z)
end

View file

@ -49,6 +49,6 @@ class User < ApplicationRecord
Transport
.current
.raw_amqp_send("X", Api::RmqUtilsController::PUBLIC_BROADCAST)
.raw_amqp_send({}.to_json, Api::RmqUtilsController::PUBLIC_BROADCAST)
end
end

View file

@ -0,0 +1,16 @@
module Alerts
class Create < Mutations::Command
required do
model :device
string :problem_tag, in: Alert::PROBLEM_TAGS
end
optional { string :slug }
def execute
Alert.create!(device: device,
problem_tag: problem_tag,
slug: slug || SecureRandom.uuid)
end
end
end

View file

@ -1,11 +1,11 @@
module Enigmas
module Alerts
class Destroy < Mutations::Command
required do
model :enigma
model :alert
end
def execute
enigma.destroy!
alert.destroy!
end
end
end

View file

@ -13,8 +13,14 @@ module Devices
def execute
merge_default_values
device = Device.create!({name: "Farmbot"}.merge(inputs.except(:user)))
Enigmas::Create.run!(device: device,
problem_tag: Enigma::SEED_DATA)
Alerts::Create.run!(device: device,
problem_tag: Alert::SEED_DATA)
Alerts::Create.run!(device: device,
problem_tag: Alert::TOUR)
Alerts::Create.run!(device: device,
problem_tag: Alert::USER)
Alerts::Create.run!(device: device,
problem_tag: Alert::DOCUMENTATION)
ActiveRecord::Base.transaction do
# TODO: This is a really, really, really old

View file

@ -0,0 +1,35 @@
module Devices
class CreateSeedData < Mutations::Command
PRODUCT_LINES = {
"express_1.0" => Devices::Seeders::ExpressOneZero,
"express_xl_1.0" => Devices::Seeders::ExpressXlOneZero,
"genesis_1.2" => Devices::Seeders::GenesisOneTwo,
"genesis_1.3" => Devices::Seeders::GenesisOneThree,
"genesis_1.4" => Devices::Seeders::GenesisOneFour,
"xl_1.4" => Devices::Seeders::XlOneFour,
"none" => Devices::Seeders::None,
}
COMMANDS = Devices::Seeders::Abstract.instance_methods(false).sort
required do
model :device
string :product_line, in: PRODUCT_LINES.keys
end
def execute
run_seeds!
end
def seeder
@seeder ||= PRODUCT_LINES.fetch(product_line).new(device)
end
def run_seeds!
COMMANDS.map { |cmd| seeder.send(cmd) }
end
end
end

View file

@ -1,24 +0,0 @@
module Devices
class SeedData < Mutations::Command
PRODUCT_LINES = ["genesis"]
required do
model :device
string :product_line, in: PRODUCT_LINES
end
def execute
add_plants
end
private
def add_plants
Plant.create!(device: device,
x: rand(40...1500),
y: rand(40...800),
radius: rand(30...60),
name: "Celery",
openfarm_slug: "celery")
end
end
end

View file

@ -0,0 +1,69 @@
module Devices
module Seeders
class Abstract
SEED_EMAIL = "seed@farmbot.io"
attr_reader :device
def initialize(device)
@device = device
end
def plants
seed_device
.plants
.as_json
.map do |x|
Points::Create.run!(x
.slice(*Plant::ATTRS)
.merge({
device: device,
pointer_type: "Plant",
}))
end
end
def peripherals_lighting; end
def peripherals_peripheral_4; end
def peripherals_peripheral_5; end
def peripherals_vacuum; end
def peripherals_water; end
def pin_bindings_button_1; end
def pin_bindings_button_2; end
def sensors_soil_sensor; end
def sensors_tool_verification; end
def sequences_mount_tool; end
def sequences_pick_up_seed; end
def sequences_plant_seed; end
def sequences_take_photo_of_plant; end
def sequences_tool_error; end
def sequences_unmount_tool; end
def sequences_water_plant; end
def settings_default_map_size_x; end
def settings_default_map_size_y; end
def settings_device_name; end
def settings_enable_encoders; end
def settings_firmware; end
def tool_slots_slot_1; end
def tool_slots_slot_2; end
def tool_slots_slot_3; end
def tool_slots_slot_4; end
def tool_slots_slot_5; end
def tool_slots_slot_6; end
def tools_seed_bin; end
def tools_seed_tray; end
def tools_seed_trough_1; end
def tools_seed_trough_2; end
def tools_seed_trough_3; end
def tools_seeder; end
def tools_soil_sensor; end
def tools_watering_nozzle; end
def tools_weeder; end
private
def seed_device
@seed_device ||= User.find_by(email: SEED_EMAIL).device
end
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class ExpressOneZero < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class ExpressXlOneZero < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class GenesisOneFour < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class GenesisOneThree < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class GenesisOneTwo < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class None < Abstract
end
end
end

View file

@ -0,0 +1,6 @@
module Devices
module Seeders
class XlOneFour < Abstract
end
end
end

View file

@ -1,14 +0,0 @@
module Enigmas
class Create < Mutations::Command
required do
model :device
string :problem_tag, in: Enigma::PROBLEM_TAGS
end
def execute
Enigma.create!(device: device,
problem_tag: problem_tag,
uuid: SecureRandom.uuid)
end
end
end

View file

@ -12,10 +12,10 @@ module Users
end
def execute
user.destroy!
user.delay.destroy!
end
private
private
def confirm_password
invalid = !user.valid_password?(password)

View file

@ -1,5 +1,5 @@
class EnigmaSerializer < ApplicationSerializer
attributes :created_at, :id, :priority, :problem_tag, :updated_at, :uuid
class AlertSerializer < ApplicationSerializer
attributes :created_at, :id, :priority, :problem_tag, :updated_at, :slug
def created_at
object.created_at.to_i

View file

@ -0,0 +1,3 @@
class GlobalBulletinSerializer < ApplicationSerializer
attributes :href, :href_label, :slug, :title, :type, :content
end

View file

@ -13,7 +13,7 @@ module FarmBot
REDIS_ENV_KEY = ENV.fetch("WHERE_IS_REDIS_URL", "REDIS_URL")
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
config.cache_store = :redis_cache_store, { url: REDIS_URL }
# config.middleware.use Rack::Attack
config.middleware.use Rack::Attack
config.active_record.schema_format = :sql
config.active_job.queue_adapter = :delayed_job
config.action_dispatch.perform_deep_munge = false

View file

@ -1,19 +1,18 @@
# RC 17 APR 19
# class Rack::Attack
# ### Throttle Spammy Clients ###
# throttle('req/ip', limit: 1000, period: 1.minutes) do |req|
# req.ip
# end
class Rack::Attack
### Throttle Spammy Clients ###
throttle('req/ip', limit: 2000, period: 1.minutes) do |req|
req.ip
end
# ### Stop people from overusing the sync object. ###
# throttle('sync_req/ip', limit: 5, period: 1.minutes) do |req|
# req.ip if req.url.include?("/sync")
# end
# end
### Stop people from overusing the sync object. ###
throttle('sync_req/ip', limit: 10, period: 1.minutes) do |req|
req.ip if req.url.include?("/sync")
end
end
# # Always allow requests from localhost
# # (blacklist & throttles are skipped)
# Rack::Attack.safelist('allow from localhost') do |req|
# # Requests are allowed if the return value is truthy
# '127.0.0.1' == req.ip || '::1' == req.ip
# end
# Always allow requests from localhost
# (blacklist & throttles are skipped)
Rack::Attack.safelist('allow from localhost') do |req|
# Requests are allowed if the return value is truthy
'127.0.0.1' == req.ip || '::1' == req.ip
end

View file

@ -8,9 +8,10 @@ FarmBot::Application.routes.draw do
# Standard API Resources:
{
diagnostic_dumps: [:create, :destroy, :index],
enigmas: [:create, :destroy, :index],
alerts: [:create, :destroy, :index],
farm_events: [:create, :destroy, :index, :show, :update],
farmware_envs: [:create, :destroy, :index, :show, :update],
global_bulletins: [:show],
images: [:create, :destroy, :index, :show],
password_resets: [:create, :update],
peripherals: [:create, :destroy, :index, :show, :update],

View file

@ -0,0 +1,7 @@
class StricterValidationForToolId < ActiveRecord::Migration[5.2]
def change
safety_assured do
add_index :points, [:device_id, :tool_id], unique: true
end
end
end

View file

@ -0,0 +1,13 @@
class CreateGlobalBulletin < ActiveRecord::Migration[5.2]
def change
create_table :global_bulletins do |t|
t.string :href
t.string :href_label
t.string :slug
t.string :title
t.string :type
t.text :content
t.timestamps
end
end
end

View file

@ -0,0 +1,11 @@
class DropEnigmasTable < ActiveRecord::Migration[5.2]
def change
drop_table :enigmas do |t|
t.string :problem_tag, null: false
t.integer :priority, default: 100, null: false
t.string :uuid, null: false
t.references :device, foreign_key: true, null: false
t.timestamps
end
end
end

View file

@ -0,0 +1,11 @@
class CreateAlertsTable < ActiveRecord::Migration[5.2]
def change
create_table :alerts do |t|
t.string :problem_tag, null: false
t.integer :priority, default: 100, null: false
t.string :slug, null: false
t.references :device, foreign_key: true, null: false
t.timestamps
end
end
end

View file

@ -42,6 +42,40 @@ SET default_tablespace = '';
SET default_with_oids = false;
--
-- Name: alerts; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.alerts (
id bigint NOT NULL,
problem_tag character varying NOT NULL,
priority integer DEFAULT 100 NOT NULL,
slug character varying NOT NULL,
device_id bigint NOT NULL,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
--
-- Name: alerts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.alerts_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: alerts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.alerts_id_seq OWNED BY public.alerts.id;
--
-- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: -
--
@ -272,40 +306,6 @@ CREATE SEQUENCE public.edge_nodes_id_seq
ALTER SEQUENCE public.edge_nodes_id_seq OWNED BY public.edge_nodes.id;
--
-- Name: enigmas; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.enigmas (
id bigint NOT NULL,
problem_tag character varying NOT NULL,
priority integer DEFAULT 100 NOT NULL,
uuid character varying NOT NULL,
device_id bigint NOT NULL,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
--
-- Name: enigmas_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.enigmas_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: enigmas_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.enigmas_id_seq OWNED BY public.enigmas.id;
--
-- Name: farm_events; Type: TABLE; Schema: public; Owner: -
--
@ -618,6 +618,42 @@ CREATE SEQUENCE public.fragments_id_seq
ALTER SEQUENCE public.fragments_id_seq OWNED BY public.fragments.id;
--
-- Name: global_bulletins; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.global_bulletins (
id bigint NOT NULL,
href character varying,
href_label character varying,
slug character varying,
title character varying,
type character varying,
content text,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
--
-- Name: global_bulletins_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.global_bulletins_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: global_bulletins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.global_bulletins_id_seq OWNED BY public.global_bulletins.id;
--
-- Name: global_configs; Type: TABLE; Schema: public; Owner: -
--
@ -1511,7 +1547,8 @@ CREATE TABLE public.web_app_configs (
show_dev_menu boolean DEFAULT false,
internal_use text,
time_format_24_hour boolean DEFAULT false,
show_pins boolean DEFAULT false
show_pins boolean DEFAULT false,
disable_emergency_unlock_confirmation boolean DEFAULT false
);
@ -1567,6 +1604,13 @@ CREATE SEQUENCE public.webcam_feeds_id_seq
ALTER SEQUENCE public.webcam_feeds_id_seq OWNED BY public.webcam_feeds.id;
--
-- Name: alerts id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.alerts ALTER COLUMN id SET DEFAULT nextval('public.alerts_id_seq'::regclass);
--
-- Name: arg_names id; Type: DEFAULT; Schema: public; Owner: -
--
@ -1609,13 +1653,6 @@ ALTER TABLE ONLY public.diagnostic_dumps ALTER COLUMN id SET DEFAULT nextval('pu
ALTER TABLE ONLY public.edge_nodes ALTER COLUMN id SET DEFAULT nextval('public.edge_nodes_id_seq'::regclass);
--
-- Name: enigmas id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.enigmas ALTER COLUMN id SET DEFAULT nextval('public.enigmas_id_seq'::regclass);
--
-- Name: farm_events id; Type: DEFAULT; Schema: public; Owner: -
--
@ -1658,6 +1695,13 @@ ALTER TABLE ONLY public.firmware_configs ALTER COLUMN id SET DEFAULT nextval('pu
ALTER TABLE ONLY public.fragments ALTER COLUMN id SET DEFAULT nextval('public.fragments_id_seq'::regclass);
--
-- Name: global_bulletins id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.global_bulletins ALTER COLUMN id SET DEFAULT nextval('public.global_bulletins_id_seq'::regclass);
--
-- Name: global_configs id; Type: DEFAULT; Schema: public; Owner: -
--
@ -1826,6 +1870,14 @@ ALTER TABLE ONLY public.web_app_configs ALTER COLUMN id SET DEFAULT nextval('pub
ALTER TABLE ONLY public.webcam_feeds ALTER COLUMN id SET DEFAULT nextval('public.webcam_feeds_id_seq'::regclass);
--
-- Name: alerts alerts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.alerts
ADD CONSTRAINT alerts_pkey PRIMARY KEY (id);
--
-- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -1882,14 +1934,6 @@ ALTER TABLE ONLY public.edge_nodes
ADD CONSTRAINT edge_nodes_pkey PRIMARY KEY (id);
--
-- Name: enigmas enigmas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.enigmas
ADD CONSTRAINT enigmas_pkey PRIMARY KEY (id);
--
-- Name: farm_events farm_events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -1938,6 +1982,14 @@ ALTER TABLE ONLY public.fragments
ADD CONSTRAINT fragments_pkey PRIMARY KEY (id);
--
-- Name: global_bulletins global_bulletins_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.global_bulletins
ADD CONSTRAINT global_bulletins_pkey PRIMARY KEY (id);
--
-- Name: global_configs global_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -2145,6 +2197,13 @@ ALTER TABLE ONLY public.webcam_feeds
CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, run_at);
--
-- Name: index_alerts_on_device_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_alerts_on_device_id ON public.alerts USING btree (device_id);
--
-- Name: index_arg_sets_on_fragment_id; Type: INDEX; Schema: public; Owner: -
--
@ -2201,13 +2260,6 @@ CREATE INDEX index_edge_nodes_on_primary_node_id ON public.edge_nodes USING btre
CREATE INDEX index_edge_nodes_on_sequence_id ON public.edge_nodes USING btree (sequence_id);
--
-- Name: index_enigmas_on_device_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_enigmas_on_device_id ON public.enigmas USING btree (device_id);
--
-- Name: index_farm_events_on_device_id; Type: INDEX; Schema: public; Owner: -
--
@ -2404,6 +2456,13 @@ CREATE INDEX index_plant_templates_on_saved_garden_id ON public.plant_templates
CREATE INDEX index_points_on_device_id ON public.points USING btree (device_id);
--
-- Name: index_points_on_device_id_and_tool_id; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX index_points_on_device_id_and_tool_id ON public.points USING btree (device_id, tool_id);
--
-- Name: index_points_on_discarded_at; Type: INDEX; Schema: public; Owner: -
--
@ -2664,14 +2723,6 @@ ALTER TABLE ONLY public.sensor_readings
ADD CONSTRAINT fk_rails_04297fb1ff FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: enigmas fk_rails_10ebd17bff; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.enigmas
ADD CONSTRAINT fk_rails_10ebd17bff FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: pin_bindings fk_rails_1f1c3b6979; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -2712,6 +2763,14 @@ ALTER TABLE ONLY public.farmware_envs
ADD CONSTRAINT fk_rails_bdadc396eb FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: alerts fk_rails_c0132c78be; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.alerts
ADD CONSTRAINT fk_rails_c0132c78be FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: diagnostic_dumps fk_rails_c5df7fdc83; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -2893,6 +2952,11 @@ INSERT INTO "schema_migrations" (version) VALUES
('20190411152319'),
('20190411171401'),
('20190411222900'),
('20190416035406');
('20190416035406'),
('20190417165636'),
('20190419001321'),
('20190419052844'),
('20190419174728'),
('20190419174811');

View file

@ -52,7 +52,7 @@ export let bot: Everything["bot"] = {
"process_info": {
"farmwares": {}
},
"enigmas": {},
"alerts": {},
},
"dirty": false,
"currentOSVersion": "3.1.6",

View file

@ -24,7 +24,7 @@ import {
TaggedToolSlotPointer,
TaggedFarmwareEnv,
TaggedFarmwareInstallation,
TaggedEnigma,
TaggedAlert,
} from "farmbot";
import { fakeResource } from "../fake_resource";
import { ExecutableType, PinBindingType } from "farmbot/dist/resources/api_resources";
@ -430,9 +430,9 @@ export function fakeFarmwareInstallation(): TaggedFarmwareInstallation {
});
}
export function fakeEnigma(): TaggedEnigma {
return fakeResource("Enigma", {
uuid: "uuid",
export function fakeAlert(): TaggedAlert {
return fakeResource("Alert", {
slug: "slug",
created_at: 123,
problem_tag: "api.noun.verb",
priority: 100,

View file

@ -364,7 +364,7 @@ const KIND_PRIORITY: ResourceLookupTable = {
Point: 1,
Sensor: 1,
Tool: 1,
Enigma: 1,
Alert: 1,
SensorReading: 2,
Sequence: 2,
Regimen: 3,

View file

@ -1,14 +0,0 @@
import * as React from "react";
import { DeleteAccount } from "../components/delete_account";
import { mount } from "enzyme";
describe("<DeleteAccount/>", () => {
it("executes account deletion", () => {
const fn = jest.fn();
const el = mount(<DeleteAccount onClick={fn} />);
el.setState({ password: "123" });
el.find("button.red").last().simulate("click");
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("123");
});
});

View file

@ -0,0 +1,35 @@
import * as React from "react";
import { DeleteAccount } from "../delete_account";
import { mount, shallow } from "enzyme";
import { DeleteAccountProps } from "../../interfaces";
import { BlurablePassword } from "../../../ui/blurable_password";
describe("<DeleteAccount/>", () => {
const fakeProps = (): DeleteAccountProps => ({
onClick: jest.fn(),
});
it("executes account deletion", () => {
const p = fakeProps();
const wrapper = mount(<DeleteAccount {...p} />);
wrapper.setState({ password: "123" });
wrapper.find("button.red").last().simulate("click");
expect(p.onClick).toHaveBeenCalledTimes(1);
expect(p.onClick).toHaveBeenCalledWith("123");
});
it("enters password", () => {
const wrapper = shallow<DeleteAccount>(<DeleteAccount {...fakeProps()} />);
wrapper.find(BlurablePassword).simulate("commit", {
currentTarget: { value: "password" }
});
expect(wrapper.state().password).toEqual("password");
});
it("enters password", () => {
const wrapper = mount<DeleteAccount>(<DeleteAccount {...fakeProps()} />);
wrapper.setState({ password: "password" });
wrapper.unmount();
expect(wrapper).toEqual({});
});
});

View file

@ -1,5 +1,5 @@
import * as React from "react";
import { ExportAccountPanel } from "../components/export_account_panel";
import { ExportAccountPanel } from "../export_account_panel";
import { mount } from "enzyme";
describe("<ExportAccountPanel/>", () => {

View file

@ -1,9 +1,9 @@
import * as React from "react";
import { ChangePassword } from "../components/index";
import { ChangePassword } from "../change_password";
import { mount } from "enzyme";
import { SpecialStatus } from "farmbot";
import * as moxios from "moxios";
import { API } from "../../api/api";
import { API } from "../../../api/api";
import { error } from "farmbot-toastr";
describe("<ChangePassword/>", function () {

View file

@ -1,8 +1,8 @@
import * as React from "react";
import { mount } from "enzyme";
import { Settings } from "../components";
import { SettingsPropTypes } from "../interfaces";
import { fakeUser } from "../../__test_support__/fake_state/resources";
import { Settings } from "../settings";
import { SettingsPropTypes } from "../../interfaces";
import { fakeUser } from "../../../__test_support__/fake_state/resources";
describe("<Settings/>", function () {
it("saves user settings", function () {

View file

@ -9,11 +9,13 @@ describe("API", () => {
API.setBaseUrl(BASE);
[
[API.current.pointSearchPath, BASE + "/api/points/search"],
[API.current.allPointsPath, BASE + "/api/points/?filter=all"],
[API.current.sensorReadingPath, BASE + "/api/sensor_readings"],
[API.current.farmwareEnvPath, BASE + "/api/farmware_envs/"],
[API.current.plantTemplatePath, BASE + "/api/plant_templates/"],
[API.current.diagnosticDumpsPath, BASE + "/api/diagnostic_dumps/"],
[API.current.farmwareInstallationPath, BASE + "/api/farmware_installations/"],
[API.current.globalBulletinPath, BASE + "/api/global_bulletins/"],
].map(x => expect(x[0]).toEqual(x[1]));
});

View file

@ -152,7 +152,9 @@ export class API {
get farmwareInstallationPath() {
return `${this.baseUrl}/api/farmware_installations/`;
}
/** /api/enigmas/:id */
get enigmaPath() { return `${this.baseUrl}/api/enigmas/`; }
/** /api/alerts/:id */
get alertPath() { return `${this.baseUrl}/api/alerts/`; }
/** /api/global_bulletins/:id */
get globalBulletinPath() { return `${this.baseUrl}/api/global_bulletins/`; }
get syncPatch() { return `${this.baseUrl}/api/device/sync/`; }
}

View file

@ -261,7 +261,7 @@ export function urlFor(tag: ResourceName) {
PlantTemplate: API.current.plantTemplatePath,
FarmwareEnv: API.current.farmwareEnvPath,
FarmwareInstallation: API.current.farmwareInstallationPath,
Enigma: API.current.enigmaPath,
Alert: API.current.alertPath,
};
const url = OPTIONS[tag];
if (url) {

View file

@ -14,6 +14,7 @@ const BLACKLIST: ResourceName[] = [
"User",
"WebAppConfig",
"WebcamFeed",
"Alert",
];
export function maybeStartTracking(uuid: string) {

View file

@ -316,12 +316,13 @@ export namespace ToolTips {
// Tools
export const TOOL_LIST =
trim(`This is a list of all your FarmBot tools and seed containers. Click the Edit button
to add, edit, or delete tools or seed containers.`);
trim(`This is a list of all your FarmBot tools and seed containers.
Click the Edit button to add, edit, or delete tools or seed containers.`);
export const TOOLBAY_LIST =
trim(`Tool slots are where you store your FarmBot tools and seed containers, which should be
reflective of your real FarmBot hardware configuration.`);
trim(`Tool slots are where you store your FarmBot tools and seed
containers, which should be reflective of your real FarmBot hardware
configuration.`);
// Logs
export const LOGS =
@ -420,6 +421,11 @@ export namespace Content {
trim(`When you're finished with a message, press the x button in the
top right of the card to dismiss it.`);
export const FIRMWARE_MISSING =
trim(`Please choose a firmware version to install. Your choice should be
based on the type of electronics in your FarmBot according to the reference
table below.`);
// App Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when deleting a sequence step.`);

View file

@ -176,6 +176,17 @@ fieldset {
}
}
.voltage-display {
display: flex;
.saucer {
height: 1rem;
width: 1rem;
cursor: default;
margin-left: 0.5rem;
margin-top: 0.2rem;
}
}
.wifi-strength-display {
position: relative;
.percent-bar {
@ -793,7 +804,7 @@ ul {
color: $panel_yellow;
}
.empty-state-graphic {
filter: sepia() contrast(1.2) saturate(1.2);
filter: sepia(1) contrast(1.2) saturate(1.2);
}
}
}
@ -939,7 +950,7 @@ ul {
}
.messages-page {
max-width: 600px;
max-width: 785px;
padding-left: 2rem !important;
padding-right: 2rem !important;
.link-to-logs {
@ -1104,7 +1115,7 @@ ul {
.tour-list {
margin: auto;
width: 75%;
max-width: 300px;
margin-top: 1rem;
}
@ -1171,9 +1182,23 @@ ul {
box-shadow: 0px 2px 5px $medium_gray;
background: $off_white;
.problem-alert-title {
position: relative;
margin-bottom: 1rem;
line-height: 1rem;
.fa-exclamation-triangle,
.fa-check-square,
.fa-info-circle {
font-size: 1.6rem;
padding-right: 0.5rem;
}
.fa-exclamation-triangle {
color: $orange;
padding-right: 0.5rem;
}
.fa-check-square {
color: $green;
}
.fa-info-circle {
color: $blue;
}
h3 {
color: $dark_gray;
@ -1182,19 +1207,36 @@ ul {
}
p {
display: inline;
padding: 1rem;
color: $medium_gray;
font-size: 1.2rem;
margin-left: 2rem;
white-space: pre;
}
.fa-times {
position: absolute;
top: 0;
right: 0;
color: $medium_light_gray;
float: right;
&:hover {
color: $dark_gray;
}
}
}
.problem-alert-content {
.markdown {
p {
display: block;
color: $dark_gray;
text-overflow: inherit;
overflow: inherit;
width: inherit;
white-space: inherit;
}
ul {
list-style-type: disc !important;
padding-inline-start: 40px;
}
}
p {
margin-bottom: 0.75rem !important;
font-size: 1.4rem;
@ -1215,5 +1257,32 @@ ul {
float: none;
margin-bottom: 2rem;
}
.fb-button {
margin-top: 0.5rem;
}
}
}
.firmware-alerts {
max-width: 600px;
}
.firmware-hardware-choice-table {
margin: 2rem;
margin-top: 1rem;
width: 93%;
border: 1px solid $gray;
font-size: 1.2rem;
th {
background: $light_gray;
font-weight: normal;
}
td {
background: $off_white;
color: $medium_gray;
}
code {
background: $light_gray;
color: $dark_gray;
}
}

View file

@ -4,7 +4,9 @@ jest.mock("../../../../api/crud", () => ({
}));
import * as React from "react";
import { FbosDetails, colorFromTemp, betaReleaseOptIn } from "../fbos_details";
import {
FbosDetails, colorFromTemp, betaReleaseOptIn, colorFromThrottle, ThrottleType
} from "../fbos_details";
import { shallow, mount } from "enzyme";
import { bot } from "../../../../__test_support__/fake_state/bot";
import { FbosDetailsProps } from "../interfaces";
@ -153,6 +155,20 @@ describe("<FbosDetails/>", () => {
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text()).toContain("2 days");
});
it("doesn't display when throttled value is undefined", () => {
const p = fakeProps();
p.botInfoSettings.throttled = undefined;
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("voltage");
});
it("displays voltage indicator", () => {
const p = fakeProps();
p.botInfoSettings.throttled = "0x0";
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text().toLowerCase()).toContain("voltage");
});
});
describe("betaReleaseOptIn()", () => {
@ -219,3 +235,15 @@ describe("colorFromTemp()", () => {
expect(colorFromTemp(-1)).toEqual("lightblue");
});
});
describe("colorFromThrottle()", () => {
it("is currently throttled", () => {
expect(colorFromThrottle("0x40004", ThrottleType.Throttled)).toEqual("red");
});
it("was throttled", () => {
expect(colorFromThrottle("0x40000", ThrottleType.Throttled)).toEqual("yellow");
});
it("hasn't been throttled", () => {
expect(colorFromThrottle("0x0", ThrottleType.Throttled)).toEqual("green");
});
});

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { DropDownItem, Row, Col, FBSelect } from "../../../ui/index";
import {
CameraSelectionProps, CameraSelectionState
} from "./interfaces";
@ -69,7 +68,6 @@ export class CameraSelection
allowEmpty={false}
list={CAMERA_CHOICES()}
selectedItem={this.selectedCamera()}
placeholder="Select a camera..."
onChange={this.sendOffConfig}
extraClass={this.props.botOnline ? "" : "disabled"} />
</div>

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { Saucer } from "../../../ui/index";
import { ToggleButton } from "../../../controls/toggle_button";
import { updateConfig } from "../../actions";
import { last, isNumber } from "lodash";
@ -37,7 +36,7 @@ export function ChipTemperatureDisplay({ chip, temperature }: {
<b>{chip && chip.toUpperCase()} {t("CPU temperature")}: </b>
{temperature ? <span>{temperature}&deg;C</span> : t("unknown")}
</p>
{<Saucer color={colorFromTemp(temperature)} className={"small-inline"} />}
<Saucer color={colorFromTemp(temperature)} className={"small-inline"} />
</div>;
}
@ -65,6 +64,57 @@ export function WiFiStrengthDisplay({ wifiStrength }: {
</div>;
}
/** Available throttle info. */
export enum ThrottleType {
UnderVoltage = "UnderVoltage",
ArmFrequencyCapped = "ArmFrequencyCapped",
Throttled = "Throttled",
SoftTempLimit = "SoftTempLimit",
}
/** Bit positions for throttle flags. */
const THROTTLE_BIT_LOOKUP:
Record<ThrottleType, Record<"active" | "occurred", number>> = {
[ThrottleType.UnderVoltage]: { active: 0, occurred: 16 },
[ThrottleType.ArmFrequencyCapped]: { active: 1, occurred: 17 },
[ThrottleType.Throttled]: { active: 2, occurred: 18 },
[ThrottleType.SoftTempLimit]: { active: 3, occurred: 19 },
};
/** Return a color based on throttle flag states. */
export const colorFromThrottle =
(throttled: string, throttleType: ThrottleType) => {
const throttleCode = parseInt(throttled, 16);
const bit = THROTTLE_BIT_LOOKUP[throttleType];
// tslint:disable-next-line:no-bitwise
const active = throttleCode & (1 << bit.active);
// tslint:disable-next-line:no-bitwise
const occurred = throttleCode & (1 << bit.occurred);
if (active) {
return "red";
} else if (occurred) {
return "yellow";
} else {
return "green";
}
};
interface VoltageDisplayProps {
chip?: string;
throttled: string | undefined;
}
/** RPI throttle state display row: label, indicator. */
export const VoltageDisplay = ({ chip, throttled }: VoltageDisplayProps) =>
throttled
? <div className="voltage-display">
<p>
<b>{chip && chip.toUpperCase()} {t("Voltage")}: </b>
</p>
<Saucer className={"small-inline"}
color={colorFromThrottle(throttled, ThrottleType.UnderVoltage)} />
</div> : <div className="voltage-display" />;
/** Get the first 8 characters of a commit. */
const shortenCommit = (longCommit: string) => (longCommit || "").slice(0, 8);
@ -147,7 +197,7 @@ const BetaReleaseOptInButton =
export function FbosDetails(props: FbosDetailsProps) {
const {
env, commit, target, node_name, firmware_version, firmware_commit,
soc_temp, wifi_level, uptime, memory_usage, disk_usage
soc_temp, wifi_level, uptime, memory_usage, disk_usage, throttled
} = props.botInfoSettings;
return <div>
@ -164,6 +214,7 @@ export function FbosDetails(props: FbosDetailsProps) {
{isNumber(disk_usage) && <p><b>{t("Disk usage")}: </b>{disk_usage}%</p>}
<ChipTemperatureDisplay chip={target} temperature={soc_temp} />
<WiFiStrengthDisplay wifiStrength={wifi_level} />
<VoltageDisplay chip={target} throttled={throttled} />
<BetaReleaseOptInButton
dispatch={props.dispatch}
shouldDisplay={props.shouldDisplay}

View file

@ -39,6 +39,21 @@ export interface FirmwareHardwareStatusDetailsProps {
dispatch: Function;
}
export interface FlashFirmwareBtnProps {
apiFirmwareValue: string | undefined;
botOnline: boolean;
}
export const FlashFirmwareBtn = (props: FlashFirmwareBtnProps) => {
const { apiFirmwareValue } = props;
return <button className="fb-button yellow"
disabled={!apiFirmwareValue || !props.botOnline}
onClick={() => isFwHardwareValue(apiFirmwareValue) &&
flashFirmware(apiFirmwareValue)}>
{t("flash firmware")}
</button>;
};
export interface FirmwareActionsProps {
apiFirmwareValue: string | undefined;
botOnline: boolean;
@ -51,12 +66,7 @@ export const FirmwareActions = (props: FirmwareActionsProps) => {
{trim(`${t("Flash the")} ${lookup(apiFirmwareValue) || ""}
${t("firmware to your device")}:`)}
</p>
<button className="fb-button yellow"
disabled={!apiFirmwareValue || !props.botOnline}
onClick={() => isFwHardwareValue(apiFirmwareValue) &&
flashFirmware(apiFirmwareValue)}>
{t("flash firmware")}
</button>
<FlashFirmwareBtn {...props} />
</div>;
};
@ -98,7 +108,7 @@ export const FirmwareHardwareStatus = (props: FirmwareHardwareStatusProps) => {
const { firmware_hardware } = props.bot.hardware.configuration;
const status = props.apiFirmwareValue == firmware_hardware &&
props.apiFirmwareValue == boardType(firmware_version);
return <Popover position={Position.BOTTOM}>
return <Popover position={Position.TOP}>
<FirmwareHardwareStatusIcon
firmwareHardware={firmware_hardware}
status={status} />

View file

@ -5,7 +5,7 @@ import { ConnectivityRow, StatusRowProps } from "./connectivity_row";
import { Row, Col } from "../../ui";
import { ConnectivityDiagram } from "./diagram";
import {
ChipTemperatureDisplay, WiFiStrengthDisplay
ChipTemperatureDisplay, WiFiStrengthDisplay, VoltageDisplay
} from "../components/fbos_settings/fbos_details";
import { t } from "../../i18next_wrapper";
@ -27,7 +27,8 @@ export class Connectivity
() => this.setState({ hoveredConnection: name });
render() {
const { soc_temp, wifi_level } = this.props.bot.hardware.informational_settings;
const { informational_settings } = this.props.bot.hardware;
const { soc_temp, wifi_level, throttled } = informational_settings;
return <div className="connectivity">
<Row>
<Col md={12} lg={4}>
@ -39,6 +40,7 @@ export class Connectivity
<label>{t("Raspberry Pi Info")}</label>
<ChipTemperatureDisplay temperature={soc_temp} />
<WiFiStrengthDisplay wifiStrength={wifi_level} />
<VoltageDisplay throttled={throttled} />
</div>
</Col>
<Col md={12} lg={8}>

View file

@ -67,7 +67,7 @@ export let initialState = (): BotState => ({
process_info: {
farmwares: {},
},
enigmas: {},
alerts: {},
},
dirty: false,
currentOSVersion: undefined,

View file

@ -41,6 +41,13 @@ describe("<MapImage />", () => {
expect(wrapper.html()).toEqual("<image></image>");
});
it("renders pre-calibration preview", () => {
const p = fakeProps();
p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 });
const wrapper = mount(<MapImage {...p} />);
expect(wrapper.html()).toContain("image_url");
});
interface ExpectedData {
size: { width: number, height: number };
sx: number;

View file

@ -6,6 +6,8 @@ import { transformXY } from "../../util";
import { isNumber, round } from "lodash";
const PRECISION = 3; // Number of decimals for image placement coordinates
/** Show all images roughly on map when no calibration values are present. */
const PRE_CALIBRATION_PREVIEW = true;
/* Parse floats in camera calibration environment variables. */
const parse = (str: string | undefined) => {
@ -14,7 +16,8 @@ const parse = (str: string | undefined) => {
};
/* Check if the image has been rotated according to the calibration value. */
const isRotated = (name: string | undefined) => {
const isRotated = (name: string | undefined, noCalib: boolean) => {
if (PRE_CALIBRATION_PREVIEW && noCalib) { return true; }
return name &&
(name.includes("rotated")
|| name.includes("marked")
@ -24,6 +27,7 @@ const isRotated = (name: string | undefined) => {
/* Check if the calibration data is valid for the image provided using z. */
const cameraZCheck =
(imageZ: number | undefined, calibZ: string | undefined) => {
if (PRE_CALIBRATION_PREVIEW && !calibZ) { return true; }
const calibrationZ = parse(calibZ);
return isNumber(imageZ) && isNumber(calibrationZ) &&
Math.abs(imageZ - calibrationZ) < 5;
@ -41,8 +45,8 @@ const getImageSize = (url: string, size?: ImageSize): ImageSize => {
const imageData = new Image();
imageData.src = url;
return {
height: imageData.height,
width: imageData.width
height: imageData.height || 480,
width: imageData.width || 640
};
};
@ -121,13 +125,16 @@ export interface MapImageProps {
* Assume the image that is provided from the Farmware is rotated correctly.
* Require camera calibration data to display the image.
*/
// tslint:disable-next-line:cyclomatic-complexity
export function MapImage(props: MapImageProps) {
const { image, cameraCalibrationData, sizeOverride } = props;
const { scale, offset, origin, calibrationZ } = cameraCalibrationData;
const imageScale = parse(scale);
const imageOffsetX = parse(offset.x);
const imageOffsetY = parse(offset.y);
const imageOrigin = origin ? origin.split("\"").join("") : undefined;
const noCalib = PRE_CALIBRATION_PREVIEW && !parse(scale);
const imageScale = noCalib ? 1.5 : parse(scale);
const imageOffsetX = noCalib ? 0 : parse(offset.x);
const imageOffsetY = noCalib ? 0 : parse(offset.y);
const cleanOrigin = origin ? origin.split("\"").join("") : undefined;
const imageOrigin = noCalib ? "BOTTOM_LEFT" : cleanOrigin;
const { quadrant, xySwap } = props.mapTransformProps;
/* Check if the image exists. */
@ -140,7 +147,7 @@ export function MapImage(props: MapImageProps) {
/* Check for all necessary camera calibration and image data. */
if (isNumber(x) && isNumber(y) && height > 0 && width > 0 &&
isNumber(imageScale) && imageScale > 0 &&
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation) &&
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation, noCalib) &&
isNumber(imageOffsetX) && isNumber(imageOffsetY) && imageOrigin) {
/* Use pixel to coordinate scale to scale image. */
const size = { x: width * imageScale, y: height * imageScale };

View file

@ -53,6 +53,13 @@ describe("<FarmwarePage />", () => {
expect(wrapper.text()).toContain("Take Photo");
});
it("renders photos page by default without farmware data", () => {
const p = fakeProps();
p.farmwares = {};
const wrapper = mount(<FarmwarePage {...p} />);
expect(wrapper.text()).toContain("Take Photo");
});
const TEST_DATA = {
"Photos": ["Take Photo"],
"take-photo": ["Take Photo"],

View file

@ -37,6 +37,15 @@ describe("setActiveFarmwareByName", () => {
});
});
it("finds a farmware by name: other match", () => {
mockLastUrlChunk = "weed_detector";
setActiveFarmwareByName(["plant_detection"]);
expect(store.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_FARMWARE,
payload: "plant_detection"
});
});
it("handles undefined farmware names", () => {
mockLastUrlChunk = "some_farmware";
setActiveFarmwareByName([undefined]);

View file

@ -1,5 +1,4 @@
import * as React from "react";
import { urlFriendly } from "../util";
import { Actions } from "../constants";
import { Farmwares } from "./interfaces";
@ -13,6 +12,7 @@ import { ShouldDisplay, Feature } from "../devices/interfaces";
import { initSave } from "../api/crud";
import { TaggedFarmwareInstallation } from "farmbot";
import { t } from "../i18next_wrapper";
import { getFormattedFarmwareName } from "./index";
const DISPLAY_NAMES: Dictionary<string> = {
"Photos": t("Photos"),
@ -27,7 +27,7 @@ const farmwareListItem = (dispatch: Function, current: string | undefined) =>
type: Actions.SELECT_FARMWARE,
payload: farmwareName
});
const selected = (farmwareName == current)
const selected = (farmwareName == getFormattedFarmwareName(current || ""))
|| (!current && farmwareName == "Photos")
? "selected" : "";
const displayName = Object.keys(DISPLAY_NAMES).includes(farmwareName)
@ -107,8 +107,7 @@ export class FarmwareList
this.props.firstPartyFarmwareNames)} />
</Popover>
</div>
{["Photos", "Camera Calibration", "Weed Detector"]
.map(farmwareListItem(dispatch, current))}
{Object.keys(DISPLAY_NAMES).map(farmwareListItem(dispatch, current))}
<hr />
<label>
{t("My Farmware")}

View file

@ -24,6 +24,7 @@ import { getDevice } from "../device";
import { t } from "../i18next_wrapper";
import { isBotOnline } from "../devices/must_be_online";
import { BooleanSetting } from "../session_keys";
import { Dictionary } from "farmbot";
/** Get the correct help text for the provided Farmware. */
const getToolTipByFarmware =
@ -75,6 +76,18 @@ const getFarmwareByName =
}
};
const FARMWARE_NAMES_1ST_PARTY: Dictionary<string> = {
"take-photo": "Photos",
"camera-calibration": "Camera Calibration",
"plant-detection": "Weed Detector",
};
export const getFormattedFarmwareName = (farmwareName: string) =>
FARMWARE_NAMES_1ST_PARTY[farmwareName] || farmwareName;
export const farmwareUrlFriendly = (farmwareName: string) =>
urlFriendly(farmwareName).replace(/-/g, "_");
/** Execute a Farmware. */
const run = (farmwareName: string) => () => {
getDevice().execScript(farmwareName)
@ -116,7 +129,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
type: Actions.SELECT_FARMWARE,
payload: "Photos"
});
if (!this.current && Object.values(this.props.farmwares).length > 0) {
if (Object.values(this.props.farmwares).length > 0) {
const farmwareNames = Object.values(this.props.farmwares).map(x => x.name);
setActiveFarmwareByName(farmwareNames);
} else {
@ -127,7 +140,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
/** Load Farmware input panel contents for 1st & 3rd party Farmware. */
getPanelByFarmware(farmwareName: string) {
switch (urlFriendly(farmwareName).replace("-", "_")) {
switch (farmwareUrlFriendly(farmwareName)) {
case "take_photo":
case "photos":
return <Photos
@ -240,7 +253,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
</LeftPanel>
<CenterPanel
className={`farmware-input-panel ${activeClasses}`}
title={this.current || t("Photos")}
title={getFormattedFarmwareName(this.current || "Photos")}
helpText={getToolTipByFarmware(this.props.farmwares, this.current)
|| ToolTips.PHOTOS}
docPage={getDocLinkByFarmware(this.current)}>

View file

@ -1,15 +1,18 @@
import { store } from "../redux/store";
import { urlFriendly, lastUrlChunk } from "../util";
import { lastUrlChunk } from "../util";
import { Actions } from "../constants";
import { farmwareUrlFriendly } from "./index";
export function setActiveFarmwareByName(farmwareNames: (string | undefined)[]) {
const chunk = urlFriendly(lastUrlChunk());
const chunk = farmwareUrlFriendly(lastUrlChunk());
if (chunk == "farmware") { return; }
farmwareNames.map(payload => {
if (payload) {
const urlName = urlFriendly(payload);
const match = chunk === urlName;
const urlName = farmwareUrlFriendly(payload);
const directMatch = chunk === urlName;
const altMatch = chunk === "weed_detector" && urlName === "plant_detection";
const match = directMatch || altMatch;
match && store.dispatch({ type: Actions.SELECT_FARMWARE, payload });
}
});

View file

@ -1,9 +1,12 @@
import React from "react";
import { mount } from "enzyme";
import { ImageWorkspace, ImageWorkspaceProps } from "../image_workspace";
import { fakeImage } from "../../../__test_support__/fake_state/resources";
import { TaggedImage } from "farmbot";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { clickButton } from "../../../__test_support__/helpers";
describe("<Body/>", () => {
describe("<ImageWorkspace />", () => {
const fakeProps = (): ImageWorkspaceProps => ({
onFlip: jest.fn(),
onProcessPhoto: jest.fn(),
@ -84,4 +87,21 @@ describe("<Body/>", () => {
iw.maybeProcessPhoto();
expect(p.onProcessPhoto).toHaveBeenCalledWith(photo2.body.id);
});
it("scans image", () => {
const image = fakeImage();
const p = fakeProps();
p.botOnline = true;
p.images = [image];
const wrapper = mount(<ImageWorkspace {...p} />);
clickButton(wrapper, 0, "scan image");
expect(p.onProcessPhoto).toHaveBeenCalledWith(image.body.id);
});
it("disables scan image button when offline", () => {
const p = fakeProps();
p.botOnline = false;
const wrapper = mount(<ImageWorkspace {...p} />);
expect(wrapper.find("button").first().props().disabled).toBeTruthy();
});
});

View file

@ -1,5 +1,4 @@
import * as React from "react";
import {
BlurableInput,
Row, Col,
@ -69,8 +68,7 @@ export class WeedDetectorConfig extends React.Component<SettingsMenuProps, {}> {
<FBSelect
onChange={this.setDDI("CAMERA_CALIBRATION_calibration_along_axis")}
selectedItem={this.find("CAMERA_CALIBRATION_calibration_along_axis")}
list={CALIBRATION_DROPDOWNS}
placeholder="Select..." />
list={CALIBRATION_DROPDOWNS} />
<Row>
<Col xs={6}>
<this.NumberBox
@ -89,8 +87,7 @@ export class WeedDetectorConfig extends React.Component<SettingsMenuProps, {}> {
<FBSelect
list={ORIGIN_DROPDOWNS}
onChange={this.setDDI("CAMERA_CALIBRATION_image_bot_origin_location")}
selectedItem={this.find("CAMERA_CALIBRATION_image_bot_origin_location")}
placeholder="Select..." />
selectedItem={this.find("CAMERA_CALIBRATION_image_bot_origin_location")} />
<Row>
<Col xs={6}>
<this.NumberBox

View file

@ -156,7 +156,7 @@ export class ImageWorkspace extends React.Component<ImageWorkspaceProps, {}> {
className="green fb-button"
title="Scan this image"
onClick={this.maybeProcessPhoto}
disabled={this.props.botOnline}
disabled={!this.props.botOnline}
hidden={!this.props.images.length} >
{t("Scan image")}
</button>

View file

@ -7,9 +7,9 @@ import {
WidgetHeader,
Row,
} from "../ui/index";
import { BlurablePassword } from "../ui/blurable_password";
import { t } from "../i18next_wrapper";
import { updatePageInfo } from "../util";
export interface LoginProps {
/** Attributes */
@ -41,6 +41,7 @@ export class Login extends React.Component<LoginProps, {}> {
onLoginPasswordChange,
onToggleForgotPassword,
} = this.props;
updatePageInfo("login");
return <Col xs={12} sm={5} smOffset={1} mdOffset={0}>
<Widget>
<WidgetHeader title={"Login"} />
@ -65,6 +66,7 @@ export class Login extends React.Component<LoginProps, {}> {
type="email"
value={email || ""}
name="login_email"
autoFocus={true}
onCommit={onEmailChange} />
<label>
{t("Password")}

View file

@ -4,6 +4,8 @@ import { Color } from "../ui";
import { history } from "../history";
import { TOUR_STEPS, tourPageNavigation } from "./tours";
import { t } from "../i18next_wrapper";
import { Actions } from "../constants";
import { store } from "../redux/store";
const strings = () => ({
back: t("Back"),
@ -44,6 +46,7 @@ export class Tour extends React.Component<TourProps, TourState> {
if (type === "tour:end") {
this.setState({ run: false });
history.push("/app/messages");
store.dispatch({ type: Actions.START_TOUR, payload: undefined });
}
};

View file

@ -0,0 +1,15 @@
jest.mock("axios", () => ({
get: jest.fn(() => Promise.resolve({ data: { foo: "bar" } })),
}));
jest.mock("../../api/api", () => ({
API: { current: { globalBulletinPath: "/api/stub" } }
}));
import { fetchBulletinContent } from "../actions";
describe("fetchBulletinContent()", () => {
it("fetches data", async () => {
expect(await fetchBulletinContent("slug")).toEqual({ foo: "bar" });
});
});

View file

@ -9,28 +9,28 @@ const FIRMWARE_MISSING_ALERT: Alert = {
created_at: 123,
problem_tag: "farmbot_os.firmware.missing",
priority: 100,
uuid: "uuid",
slug: "slug",
};
const SEED_DATA_MISSING_ALERT: Alert = {
created_at: 123,
problem_tag: "api.seed_data.missing",
priority: 300,
uuid: "uuid",
slug: "slug",
};
const UNKNOWN_ALERT: Alert = {
created_at: 123,
problem_tag: "farmbot_os.firmware.alert",
priority: 200,
uuid: "uuid",
slug: "slug",
};
const UNKNOWN_ALERT_2: Alert = {
created_at: 456,
problem_tag: "farmbot_os.firmware.alert",
priority: 100,
uuid: "uuid",
slug: "slug",
};
describe("<Alerts />", () => {
@ -53,7 +53,7 @@ describe("<Alerts />", () => {
p.alerts = [FIRMWARE_MISSING_ALERT, SEED_DATA_MISSING_ALERT];
const wrapper = mount(<Alerts {...p} />);
expect(wrapper.text()).toContain("2");
expect(wrapper.text()).toContain("Your device has no firmware installed");
expect(wrapper.text()).toContain("Your device has no firmware");
expect(wrapper.text()).toContain("Choose your FarmBot");
});
@ -76,20 +76,20 @@ describe("<FirmwareAlerts />", () => {
it("renders no alerts", () => {
const p = fakeProps();
p.bot.hardware.enigmas = undefined;
p.bot.hardware.alerts = undefined;
const wrapper = mount(<FirmwareAlerts {...p} />);
expect(wrapper.html()).toEqual(`<div class="firmware-alerts"></div>`);
});
it("renders alerts", () => {
const p = fakeProps();
p.bot.hardware.enigmas = {
"uuid1": FIRMWARE_MISSING_ALERT,
"uuid2": UNKNOWN_ALERT
p.bot.hardware.alerts = {
"slug1": FIRMWARE_MISSING_ALERT,
"slug2": UNKNOWN_ALERT
};
const wrapper = mount(<FirmwareAlerts {...p} />);
expect(wrapper.text()).toContain("1");
expect(wrapper.text()).toContain("Your device has no firmware installed");
expect(wrapper.text()).toContain("Your device has no firmware");
});
});

View file

@ -1,12 +1,29 @@
jest.mock("../../devices/actions", () => ({ updateConfig: jest.fn() }));
jest.mock("../../api/crud", () => ({ destroy: jest.fn() }));
const fakeBulletin: Bulletin = {
content: "Alert content.",
href: "https://farm.bot",
href_label: "See more",
type: "info",
slug: "slug",
title: "Announcement",
};
let mockData: Bulletin | undefined = fakeBulletin;
jest.mock("../actions", () => ({
fetchBulletinContent: jest.fn(() => Promise.resolve(mockData)),
}));
import * as React from "react";
import { mount } from "enzyme";
import { AlertCard } from "../cards";
import { AlertCardProps } from "../interfaces";
import { AlertCard, changeFirmwareHardware } from "../cards";
import { AlertCardProps, Bulletin } from "../interfaces";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { FBSelect } from "../../ui";
import { destroy } from "../../api/crud";
import { updateConfig } from "../../devices/actions";
describe("<AlertCard />", () => {
const fakeProps = (): AlertCardProps => ({
@ -14,7 +31,7 @@ describe("<AlertCard />", () => {
created_at: 123,
problem_tag: "author.noun.verb",
priority: 100,
uuid: "uuid",
slug: "slug",
},
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
@ -34,10 +51,13 @@ describe("<AlertCard />", () => {
it("renders firmware card", () => {
const p = fakeProps();
p.alert.problem_tag = "farmbot_os.firmware.missing";
p.alert.created_at = 1555555555;
p.timeSettings.hour24 = false;
p.timeSettings.utcOffset = 0;
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Firmware missing");
wrapper.find(".fa-times").simulate("click");
expect(destroy).not.toHaveBeenCalled();
expect(wrapper.text()).toContain("Your device has no firmware");
expect(wrapper.find(".fa-times").length).toEqual(0);
expect(wrapper.text()).toContain("Apr");
});
it("renders seed data card", () => {
@ -68,4 +88,61 @@ describe("<AlertCard />", () => {
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Learn");
});
it("renders loading bulletin card", () => {
const p = fakeProps();
p.alert.problem_tag = "api.bulletin.unread";
const wrapper = mount(<AlertCard {...p} />);
["Loading", "Slug"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("has no content to load for bulletin card", async () => {
mockData = undefined;
const p = fakeProps();
p.alert.problem_tag = "api.bulletin.unread";
const wrapper = await mount(<AlertCard {...p} />);
["Unable to load content.", "Slug"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("renders loaded bulletin card", async () => {
const p = fakeProps();
p.alert.problem_tag = "api.bulletin.unread";
mockData = fakeBulletin;
mockData.href_label = "See more";
mockData.type = "info";
const wrapper = await mount(<AlertCard {...p} />);
["Loading...", "Slug"].map(string =>
expect(wrapper.text()).not.toContain(string));
["Announcement", "Alert content.", "See more"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("renders loaded bulletin card with missing fields", async () => {
const p = fakeProps();
p.alert.problem_tag = "api.bulletin.unread";
mockData = fakeBulletin;
mockData.href_label = undefined;
mockData.type = "unknown";
const wrapper = await mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Find out more");
});
it("hides incorrect time", () => {
const p = fakeProps();
p.alert.problem_tag = "farmbot_os.firmware.missing";
p.alert.created_at = 0;
p.timeSettings.hour24 = false;
p.timeSettings.utcOffset = 0;
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).not.toContain("Jan 1, 12:00am");
});
});
describe("changeFirmwareHardware()", () => {
it("changes firmware hardware value", () => {
changeFirmwareHardware(jest.fn())({ label: "Arduino", value: "arduino" });
expect(updateConfig).toHaveBeenCalledWith({ firmware_hardware: "arduino" });
});
});

View file

@ -27,7 +27,7 @@ describe("<Messages />", () => {
created_at: 123,
problem_tag: "author.noun.verb",
priority: 100,
uuid: "uuid",
slug: "slug",
}];
const wrapper = mount(<Messages {...p} />);
expect(wrapper.text()).toContain("Message Center");

View file

@ -7,22 +7,22 @@ import { fakeState } from "../../__test_support__/fake_state";
import { mapStateToProps } from "../state_to_props";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import {
fakeEnigma, fakeFbosConfig
fakeAlert, fakeFbosConfig
} from "../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
it("handles undefined", () => {
const state = fakeState();
state.bot.hardware.enigmas = undefined;
state.bot.hardware.alerts = undefined;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
});
it("doesn't show API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
enigma.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([enigma]);
const alert = fakeAlert();
alert.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([alert]);
mockDev = false;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
@ -30,12 +30,12 @@ describe("mapStateToProps()", () => {
it("shows API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
enigma.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([enigma]);
const alert = fakeAlert();
alert.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([alert]);
mockDev = true;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([enigma.body]);
expect(props.alerts).toEqual([alert.body]);
});
it("returns firmware value", () => {
@ -50,7 +50,7 @@ describe("mapStateToProps()", () => {
it("finds alert", () => {
const state = fakeState();
const alert = fakeEnigma();
const alert = fakeAlert();
alert.body.id = 1;
state.resources = buildResourceIndex([alert]);
const props = mapStateToProps(state);

View file

@ -0,0 +1,12 @@
import axios from "axios";
import { API } from "../api";
import { Bulletin } from "./interfaces";
const url = (slug: string) => `${API.current.globalBulletinPath}${slug}`;
export const fetchBulletinContent =
(slug: string): Promise<Bulletin | undefined> => {
return axios
.get<Bulletin>(url(slug))
.then(response => Promise.resolve(response.data));
};

View file

@ -15,29 +15,31 @@ export const sortAlerts = (alerts: Alert[]): Alert[] =>
sortBy(alerts, "priority", "created_at");
export const FirmwareAlerts = (props: FirmwareAlertsProps) => {
const alerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const alerts = betterCompact(Object.values(props.bot.hardware.alerts || {}));
const firmwareAlerts = sortAlerts(alerts)
.filter(x => x.problem_tag && x.priority && x.created_at)
.filter(x => splitProblemTag(x.problem_tag).noun === "firmware");
return <div className="firmware-alerts">
{firmwareAlerts.filter(x => x.problem_tag && x.priority && x.created_at)
.map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
{firmwareAlerts.map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
</div>;
};
export const Alerts = (props: AlertsProps) =>
<div className="problem-alerts">
<div className="problem-alerts-content">
{sortAlerts(props.alerts).map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings}
findApiAlertById={props.findApiAlertById} />)}
{sortAlerts(props.alerts)
.filter(x => x.problem_tag && x.priority && x.created_at)
.map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings}
findApiAlertById={props.findApiAlertById} />)}
</div>
</div>;

View file

@ -4,17 +4,25 @@ import {
AlertCardProps, AlertCardTemplateProps, FirmwareMissingProps,
SeedDataMissingProps, SeedDataMissingState, TourNotTakenProps,
CommonAlertCardProps,
DismissAlertProps
DismissAlertProps,
Bulletin,
BulletinAlertState
} from "./interfaces";
import { formatLogTime } from "../logs";
import {
FirmwareActions
FlashFirmwareBtn
} from "../devices/components/fbos_settings/firmware_hardware_status";
import { DropDownItem, Row, Col, FBSelect, docLink } from "../ui";
import { DropDownItem, Row, Col, FBSelect, docLink, Markdown } from "../ui";
import { Content } from "../constants";
import { TourList } from "../help/tour_list";
import { splitProblemTag } from "./alerts";
import { destroy } from "../api/crud";
import {
isFwHardwareValue
} from "../devices/components/fbos_settings/board_type";
import { updateConfig } from "../devices/actions";
import { fetchBulletinContent } from "./actions";
import { startCase } from "lodash";
export const AlertCard = (props: AlertCardProps) => {
const { alert, timeSettings, findApiAlertById, dispatch } = props;
@ -33,32 +41,85 @@ export const AlertCard = (props: AlertCardProps) => {
return <UserNotWelcomed {...commonProps} />;
case "api.documentation.unread":
return <DocumentationUnread {...commonProps} />;
case "api.bulletin.unread":
return <BulletinAlert {...commonProps} />;
default:
return UnknownAlert(commonProps);
return <UnknownAlert {...commonProps} />;
}
};
const dismissAlert = (props: DismissAlertProps) => () =>
(props.id && props.findApiAlertById && props.dispatch)
? props.dispatch(destroy(props.findApiAlertById(props.id)))
: () => { };
(props.id && props.findApiAlertById && props.dispatch) &&
props.dispatch(destroy(props.findApiAlertById(props.id)));
const timeOk = (timestamp: number) => timestamp > 1550000000;
const AlertCardTemplate = (props: AlertCardTemplateProps) => {
const { alert, findApiAlertById, dispatch } = props;
return <div className={`problem-alert ${props.className}`}>
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<i className={`fa fa-${props.iconName || "exclamation-triangle"}`} />
<h3>{t(props.title)}</h3>
<p>{formatLogTime(alert.created_at, props.timeSettings)}</p>
<i className="fa fa-times"
onClick={dismissAlert({ id: alert.id, findApiAlertById, dispatch })} />
{timeOk(alert.created_at) &&
<p>{formatLogTime(alert.created_at, props.timeSettings)}</p>}
{alert.id && <i className="fa fa-times"
onClick={dismissAlert({ id: alert.id, findApiAlertById, dispatch })} />}
</div>
<div className="problem-alert-content">
<p>{t(props.message)}</p>
<Markdown>{t(props.message)}</Markdown>
{props.children}
</div>
</div>;
};
const ICON_LOOKUP: { [x: string]: string } = {
"info": "info-circle",
"success": "check-square",
"warn": "exclamation-triangle",
};
class BulletinAlert
extends React.Component<CommonAlertCardProps, BulletinAlertState> {
state: BulletinAlertState = { bulletin: undefined, no_content: false };
componentDidMount() {
fetchBulletinContent(this.props.alert.slug)
.then(bulletin => bulletin
? this.setState({ bulletin })
: this.setState({ no_content: true }));
}
get bulletinData(): Bulletin {
return this.state.bulletin || {
content: this.state.no_content ? t("Unable to load content.")
: t("Loading..."),
href: undefined,
href_label: undefined,
type: "info",
slug: this.props.alert.slug,
title: undefined,
};
}
render() {
const { content, href, href_label, type, title } = this.bulletinData;
return <AlertCardTemplate
alert={this.props.alert}
className={"bulletin-alert"}
title={title || startCase(this.props.alert.slug)}
iconName={ICON_LOOKUP[type] || "info-circle"}
message={t(content)}
timeSettings={this.props.timeSettings}
dispatch={this.props.dispatch}
findApiAlertById={this.props.findApiAlertById}>
{href && <a className="link-button fb-button green"
href={href} target="_blank"
title={t("Open link in a new tab")}>
{href_label || t("Find out more")}
</a>}
</AlertCardTemplate>;
}
}
const UnknownAlert = (props: CommonAlertCardProps) => {
const { problem_tag, created_at, priority } = props.alert;
const { author, noun, verb } = splitProblemTag(problem_tag);
@ -74,18 +135,77 @@ const UnknownAlert = (props: CommonAlertCardProps) => {
findApiAlertById={props.findApiAlertById} />;
};
const FIRMWARE_CHOICES: DropDownItem[] = [
{ label: "Arduino/RAMPS (Genesis v1.2)", value: "arduino" },
{ label: "Farmduino (Genesis v1.3)", value: "farmduino" },
{ label: "Farmduino (Genesis v1.4)", value: "farmduino_k14" },
];
const FIRMWARE_CHOICES_DDI: { [x: string]: DropDownItem } = {};
FIRMWARE_CHOICES.map(x => FIRMWARE_CHOICES_DDI[x.value] = x);
const FirmwareChoiceTable = () =>
<table className="firmware-hardware-choice-table">
<thead>
<tr>
<th>{t("FarmBot Version")}</th>
<th>{t("Electronics Board")}</th>
<th>{t("Firmware Name")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{"Genesis v1.2"}</td>
<td>{"RAMPS"}</td>
<td><code>{FIRMWARE_CHOICES_DDI["arduino"].label}</code></td>
</tr>
<tr>
<td>{"Genesis v1.3"}</td>
<td>{"Farmduino"}</td>
<td><code>{FIRMWARE_CHOICES_DDI["farmduino"].label}</code></td>
</tr>
<tr>
<td>{"Genesis v1.4"}</td>
<td>{"Farmduino"}</td>
<td><code>{FIRMWARE_CHOICES_DDI["farmduino_k14"].label}</code></td>
</tr>
</tbody>
</table>;
export const changeFirmwareHardware = (dispatch: Function | undefined) =>
(ddi: DropDownItem) => {
if (isFwHardwareValue(ddi.value)) {
dispatch && dispatch(updateConfig({ firmware_hardware: ddi.value }));
}
};
const FirmwareMissing = (props: FirmwareMissingProps) =>
<AlertCardTemplate
alert={props.alert}
className={"firmware-missing-alert"}
title={t("Firmware missing")}
message={t("Your device has no firmware installed.")}
title={t("Your device has no firmware")}
message={t(Content.FIRMWARE_MISSING)}
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
<FirmwareActions
apiFirmwareValue={props.apiFirmwareValue}
botOnline={true} />
<Row>
<FirmwareChoiceTable />
<Col xs={4}>
<label>{t("Choose Firmware")}</label>
</Col>
<Col xs={5}>
<FBSelect
key={props.apiFirmwareValue}
list={FIRMWARE_CHOICES}
selectedItem={FIRMWARE_CHOICES_DDI[props.apiFirmwareValue || "arduino"]}
onChange={changeFirmwareHardware(props.dispatch)} />
</Col>
<Col xs={3}>
<FlashFirmwareBtn
apiFirmwareValue={props.apiFirmwareValue}
botOnline={true} />
</Col>
</Row>
</AlertCardTemplate>;
const SEED_DATA_OPTIONS: DropDownItem[] = [
@ -107,7 +227,8 @@ class SeedDataMissing
message={t(Content.SEED_DATA_SELECTION)}
timeSettings={this.props.timeSettings}
dispatch={this.props.dispatch}
findApiAlertById={this.props.findApiAlertById}>
findApiAlertById={this.props.findApiAlertById}
iconName={"check-square"}>
<Row>
<Col xs={4}>
<label>{t("Choose your FarmBot")}</label>
@ -132,7 +253,8 @@ const TourNotTaken = (props: TourNotTakenProps) =>
message={t(Content.TAKE_A_TOUR)}
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
findApiAlertById={props.findApiAlertById}
iconName={"info-circle"}>
<p>{t("Choose a tour to begin")}:</p>
<TourList dispatch={props.dispatch} />
</AlertCardTemplate>;
@ -145,10 +267,11 @@ const UserNotWelcomed = (props: CommonAlertCardProps) =>
message={t(Content.WELCOME)}
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
findApiAlertById={props.findApiAlertById}
iconName={"info-circle"}>
<p>
{t("You're currently viewing the")} <b>{t("Message Center")}</b>.
&nbsp;{t(Content.MESSAGE_CENTER_WELCOME)}
{" "}{t(Content.MESSAGE_CENTER_WELCOME)}
</p>
<p>
{t(Content.MESSAGE_DISMISS)}
@ -163,7 +286,8 @@ const DocumentationUnread = (props: CommonAlertCardProps) =>
message={t(Content.READ_THE_DOCS)}
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
findApiAlertById={props.findApiAlertById}
iconName={"info-circle"}>
<p>
{t("Head over to")}
&nbsp;<a href={docLink()} target="_blank"

View file

@ -1,4 +1,4 @@
import { FirmwareHardware, Enigma } from "farmbot";
import { FirmwareHardware, Alert as Enigma } from "farmbot";
import { TimeSettings } from "../interfaces";
import { BotState } from "../devices/interfaces";
import { UUID } from "../resources/interfaces";
@ -58,6 +58,7 @@ export interface AlertCardTemplateProps {
children?: React.ReactNode;
findApiAlertById?(id: number): UUID;
dispatch?: Function;
iconName?: string;
}
export interface DismissAlertProps {
@ -81,3 +82,17 @@ export interface SeedDataMissingState {
export interface TourNotTakenProps extends CommonAlertCardProps {
dispatch: Function;
}
export interface Bulletin {
content: string;
href: string | undefined;
href_label: string | undefined;
type: string;
slug: string;
title: string | undefined;
}
export interface BulletinAlertState {
bulletin: Bulletin | undefined;
no_content: boolean;
}

View file

@ -5,7 +5,7 @@ import { getFbosConfig } from "../resources/getters";
import { sourceFbosConfigValue } from "../devices/components/source_config_value";
import { DevSettings } from "../account/dev/dev_support";
import {
selectAllEnigmas, maybeGetTimeSettings, findResourceById
selectAllAlerts, maybeGetTimeSettings, findResourceById
} from "../resources/selectors";
import { isFwHardwareValue } from "../devices/components/fbos_settings/board_type";
import { ResourceIndex, UUID } from "../resources/interfaces";
@ -18,7 +18,7 @@ export const mapStateToProps = (props: Everything): MessagesProps => {
sourceFbosConfigValue(fbosConfig, hardware.configuration);
const apiFirmwareValue = sourceFbosConfig("firmware_hardware").value;
const findApiAlertById = (id: number): UUID =>
findResourceById(props.resources.index, "Enigma", id);
findResourceById(props.resources.index, "Alert", id);
return {
alerts: getAlerts(props.resources.index, props.bot),
apiFirmwareValue: isFwHardwareValue(apiFirmwareValue)
@ -31,8 +31,8 @@ export const mapStateToProps = (props: Everything): MessagesProps => {
export const getAlerts =
(resourceIndex: ResourceIndex, bot: BotState): Alert[] => {
const botAlerts = betterCompact(Object.values(bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(resourceIndex).map(x => x.body)
const botAlerts = betterCompact(Object.values(bot.hardware.alerts || {}));
const apiAlerts = selectAllAlerts(resourceIndex).map(x => x.body)
.filter(x => DevSettings.futureFeaturesEnabled() ||
x.problem_tag !== "api.seed_data.missing");
return botAlerts.concat(apiAlerts);

View file

@ -45,8 +45,7 @@ export class BulkScheduler extends React.Component<BulkEditorProps, {}> {
<label>{t("Sequence")}</label>
<FBSelect onChange={this.onChange}
selectedItem={this.selected()}
list={this.all()}
placeholder="Pick a sequence (or save a new one)" />
list={this.all()} />
</div>
</Col>

View file

@ -58,7 +58,7 @@ export const emptyState = (): RestResources => {
PlantTemplate: {},
SavedGarden: {},
DiagnosticDump: {},
Enigma: {},
Alert: {},
},
byKindAndId: {},
references: {},

View file

@ -20,7 +20,7 @@ import {
TaggedPlantTemplate,
TaggedFarmwareEnv,
TaggedFarmwareInstallation,
TaggedEnigma,
TaggedAlert,
} from "farmbot";
import {
isTaggedResource,
@ -100,8 +100,8 @@ export const selectAllWebcamFeeds =
(i: ResourceIndex) => findAll<TaggedWebcamFeed>(i, "WebcamFeed");
export const selectAllSavedPeripherals =
(input: ResourceIndex) => selectAllPeripherals(input).filter(isSaved);
export const selectAllEnigmas =
(i: ResourceIndex) => findAll<TaggedEnigma>(i, "Enigma");
export const selectAllAlerts =
(i: ResourceIndex) => findAll<TaggedAlert>(i, "Alert");
export const findByKindAndId = <T extends TaggedResource>(
i: ResourceIndex, kind: T["kind"], id: number | undefined): T => {

View file

@ -204,7 +204,7 @@ export class SequenceEditorMiddleActive extends
farmwareInfo: this.props.farmwareInfo,
shouldDisplay: this.props.shouldDisplay,
confirmStepDeletion: !!getConfig(BooleanSetting.confirm_step_deletion),
showPins: !!getConfig(BooleanSetting.confirm_step_deletion),
showPins: !!getConfig(BooleanSetting.show_pins),
};
}

View file

@ -40,6 +40,5 @@ export function SequenceSelectBox(props: SequenceSelectBoxProps) {
return <FBSelect
onChange={props.onChange}
selectedItem={selectedSequence()}
list={sequenceDropDownList()}
placeholder="Pick a sequence (or save a new one)" />;
list={sequenceDropDownList()} />;
}

View file

@ -131,6 +131,18 @@ describe("Pin and Peripheral support files", () => {
expect(JSON.stringify(result)).toContain("displayed peripheral");
expect(JSON.stringify(result)).not.toContain("not displayed");
});
it("doesn't display pins", () => {
const ri = buildResourceIndex([]);
const result = pinsAsDropDownsWritePin(ri.index, () => false, false);
expect(JSON.stringify(result)).not.toContain("Pin 13");
});
it("displays pins", () => {
const ri = buildResourceIndex([]);
const result = pinsAsDropDownsWritePin(ri.index, () => true, true);
expect(JSON.stringify(result)).toContain("Pin 13");
});
});
describe("pinsAsDropDownsReadPin()", () => {
@ -154,6 +166,18 @@ describe("Pin and Peripheral support files", () => {
expect(JSON.stringify(result)).toContain("displayed sensor");
expect(JSON.stringify(result)).toContain("displayed peripheral");
});
it("doesn't display pins", () => {
const ri = buildResourceIndex([]);
const result = pinsAsDropDownsReadPin(ri.index, () => false, false);
expect(JSON.stringify(result)).not.toContain("Pin 13");
});
it("displays pins", () => {
const ri = buildResourceIndex([]);
const result = pinsAsDropDownsReadPin(ri.index, () => true, true);
expect(JSON.stringify(result)).toContain("Pin 13");
});
});
describe("findByPinNumber", () => {

View file

@ -24,7 +24,8 @@ describe("<TileIf/>", () => {
dispatch={jest.fn()}
index={0}
resources={emptyState().index}
confirmStepDeletion={false} />)
confirmStepDeletion={false}
showPins={true} />)
};
}

View file

@ -37,16 +37,16 @@ const BOX_LED_LABELS: { [x: string]: string } = {
};
export const PERIPHERAL_HEADING: DropDownItem =
({ heading: true, label: t("Peripherals"), value: 0 });
({ heading: true, label: t("Peripherals"), value: 0, headingId: PinGroupName.Peripheral });
export const SENSOR_HEADING: DropDownItem =
({ heading: true, label: t("Sensors"), value: 0 });
({ heading: true, label: t("Sensors"), value: 0, headingId: PinGroupName.Sensor });
export const BOX_LED_HEADING: DropDownItem =
({ heading: true, label: t("Box LEDs"), value: 0 });
({ heading: true, label: t("Box LEDs"), value: 0, headingId: PinGroupName.BoxLed });
export const PIN_HEADING: DropDownItem =
({ heading: true, label: t("Pins"), value: 0 });
({ heading: true, label: t("Pins"), value: 0, headingId: PinGroupName.Pin });
/** Pass it the number X and it will generate a DropDownItem for `pin x`. */
export const pinNumber2DropDown =

View file

@ -11,7 +11,8 @@ export function TileIf(props: StepParams) {
index={props.index}
resources={props.resources}
shouldDisplay={props.shouldDisplay}
confirmStepDeletion={props.confirmStepDeletion} />;
confirmStepDeletion={props.confirmStepDeletion}
showPins={props.showPins} />;
} else {
return <p> Expected "_if" node</p>;
}

View file

@ -1,10 +1,14 @@
jest.mock("../../../../api/crud", () => ({ overwrite: jest.fn() }));
import * as React from "react";
import { If_ } from "../if";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { fakeSequence } from "../../../../__test_support__/fake_state/resources";
import { If } from "farmbot/dist";
import { IfParams } from "../index";
import { emptyState } from "../../../../resources/reducer";
import { FBSelect } from "../../../../ui";
import { overwrite } from "../../../../api/crud";
describe("<If_/>", () => {
function fakeProps(): IfParams {
@ -26,6 +30,7 @@ describe("<If_/>", () => {
resources: emptyState().index,
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
showPins: true,
};
}
@ -36,4 +41,15 @@ describe("<If_/>", () => {
expect(wrapper.find("button").length).toEqual(2);
expect(wrapper.find("input").length).toEqual(1);
});
it("updates op", () => {
const wrapper = shallow(<If_ {...fakeProps()} />);
wrapper.find(FBSelect).last().simulate("change", {
label: "is not", value: "not"
});
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({
body: [{ kind: "_if", args: expect.objectContaining({ op: "not" }) }]
}));
});
});

View file

@ -47,6 +47,7 @@ function fakeProps(): IfParams {
resources: fakeResourceIndex,
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
showPins: true,
};
}
@ -60,16 +61,16 @@ describe("seqDropDown()", () => {
});
describe("LHSOptions()", () => {
it("returns positions and pins", () => {
it("returns positions", () => {
const s = fakeSensor();
const p = fakePeripheral();
s.body.label = "not displayed";
p.body.label = "not displayed";
const ri = buildResourceIndex([s, p]);
const result = JSON.stringify(LHSOptions(ri.index, () => false));
const result = JSON.stringify(LHSOptions(ri.index, () => false, false));
expect(result).not.toContain("not displayed");
expect(result).toContain("X position");
expect(result).toContain("Pin 25");
expect(result).not.toContain("Pin 25");
});
it("returns positions, peripherals, sensors, pins", () => {
@ -78,8 +79,9 @@ describe("LHSOptions()", () => {
s.body.label = "displayed";
p.body.label = "displayed";
const ri = buildResourceIndex([s, p]);
const result = JSON.stringify(LHSOptions(ri.index, () => true));
const result = JSON.stringify(LHSOptions(ri.index, () => true, true));
expect(result).toContain("displayed");
expect(result).toContain("Pin 25");
});
});

View file

@ -1,15 +1,9 @@
import * as React from "react";
import { IfParams, LHSOptions, operatorOptions } from "./index";
import { StepInputBox } from "../../inputs/step_input_box";
import { defensiveClone } from "../../../util";
import { overwrite } from "../../../api/crud";
import {
Col,
Row,
FBSelect,
DropDownItem
} from "../../../ui/index";
import { Col, Row, FBSelect, DropDownItem } from "../../../ui";
import { ALLOWED_OPS } from "farmbot/dist";
import { updateLhs } from "./update_lhs";
import { displayLhs } from "./display_lhs";
@ -25,31 +19,25 @@ const label_ops: Record<ALLOWED_OPS, string> = {
"not": t("is not")
};
// tslint:disable-next-line:no-any
const isOp = (x: any): x is ALLOWED_OPS => Object.keys(label_ops).includes(x);
const updateOp = (props: IfParams) => (ddi: DropDownItem) => {
const stepCopy = defensiveClone(props.currentStep);
const seqCopy = defensiveClone(props.currentSequence).body;
const val = ddi.value;
seqCopy.body = seqCopy.body || [];
if (isString(val) && isOp(val)) { stepCopy.args.op = val; }
seqCopy.body[props.index] = stepCopy;
props.dispatch(overwrite(props.currentSequence, seqCopy));
};
export function If_(props: IfParams) {
const {
dispatch,
currentStep,
index,
resources
} = props;
const step = props.currentStep;
const { currentStep, resources } = props;
const sequence = props.currentSequence;
const { op } = currentStep.args;
const cb = props.shouldDisplay || (() => false);
const lhsOptions = LHSOptions(props.resources, cb);
function updateField(field: "lhs" | "op") {
return (e: DropDownItem) => {
const stepCopy = defensiveClone(step);
const seqCopy = defensiveClone(sequence).body;
const val = e.value;
seqCopy.body = seqCopy.body || [];
if (isString(val)) {
stepCopy.args[field] = val;
}
seqCopy.body[index] = stepCopy;
dispatch(overwrite(sequence, seqCopy));
};
}
const lhsOptions = LHSOptions(resources, cb, !!props.showPins);
return <Row>
<Col xs={12}>
@ -58,27 +46,25 @@ export function If_(props: IfParams) {
<Col xs={4}>
<label>{t("Variable")}</label>
<FBSelect
key={JSON.stringify(props.currentSequence)}
key={JSON.stringify(sequence)}
list={lhsOptions}
placeholder="Left hand side"
onChange={updateLhs(props)}
selectedItem={displayLhs({ currentStep, resources, lhsOptions })} />
</Col>
<Col xs={4}>
<label>{t("Operator")}</label>
<FBSelect
key={JSON.stringify(props.currentSequence)}
key={JSON.stringify(sequence)}
list={operatorOptions}
placeholder="Operation"
onChange={updateField("op")}
selectedItem={{ label: label_ops[op as ALLOWED_OPS] || op, value: op }} />
onChange={updateOp(props)}
selectedItem={{ label: label_ops[op] || op, value: op }} />
</Col>
<Col xs={4} hidden={op === IS_UNDEFINED}>
<label>{t("Value")}</label>
<StepInputBox dispatch={dispatch}
<StepInputBox dispatch={props.dispatch}
step={currentStep}
sequence={sequence}
index={index}
index={props.index}
field="rhs" />
</Col>
</Row>;

View file

@ -1,10 +1,11 @@
import * as React from "react";
import { DropDownItem, NULL_CHOICE } from "../../../ui/index";
import { TaggedSequence, ParameterApplication } from "farmbot";
import { If, Execute, Nothing } from "farmbot/dist";
import { ResourceIndex } from "../../../resources/interfaces";
import { selectAllSequences, findSequenceById } from "../../../resources/selectors";
import {
selectAllSequences, findSequenceById
} from "../../../resources/selectors";
import { isRecursive } from "../index";
import { If_ } from "./if";
import { ThenElse } from "./then_else";
@ -13,11 +14,13 @@ import { overwrite } from "../../../api/crud";
import { ToolTips } from "../../../constants";
import { StepWrapper, StepHeader, StepContent } from "../../step_ui/index";
import {
sensorsAsDropDowns, peripheralsAsDropDowns, pinDropdowns
sensorsAsDropDowns, peripheralsAsDropDowns, pinDropdowns, PinGroupName
} from "../pin_and_peripheral_support";
import { ShouldDisplay, Feature } from "../../../devices/interfaces";
import { isNumber, isString } from "lodash";
import { addOrEditParamApps, variableList } from "../../locals_list/variable_support";
import {
addOrEditParamApps, variableList
} from "../../locals_list/variable_support";
import { t } from "../../../i18next_wrapper";
export interface IfParams {
@ -28,6 +31,7 @@ export interface IfParams {
resources: ResourceIndex;
shouldDisplay?: ShouldDisplay;
confirmStepDeletion: boolean;
showPins?: boolean;
}
export interface ThenElseParams extends IfParams {
@ -41,15 +45,15 @@ export type Operator = "lhs"
| "_else";
export const LHSOptions =
(resources: ResourceIndex, shouldDisplay: ShouldDisplay
(resources: ResourceIndex, shouldDisplay: ShouldDisplay, showPins: boolean
): DropDownItem[] => [
{ heading: true, label: t("Positions"), value: 0 },
{ heading: true, label: t("Positions"), value: 0, headingId: PinGroupName.Position },
{ value: "x", label: t("X position"), headingId: "Position" },
{ value: "y", label: t("Y position"), headingId: "Position" },
{ value: "z", label: t("Z position"), headingId: "Position" },
...(shouldDisplay(Feature.named_pins) ? peripheralsAsDropDowns(resources) : []),
...(shouldDisplay(Feature.named_pins) ? sensorsAsDropDowns(resources) : []),
...pinDropdowns(n => `pin${n}`),
...(showPins ? pinDropdowns(n => `pin${n}`) : []),
];
export const operatorOptions: DropDownItem[] = [

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { ThenElseParams, seqDropDown, IfBlockDropDownHandler } from "./index";
import { Row, Col, FBSelect } from "../../../ui";
import { LocalsList } from "../../locals_list/locals_list";
import { AllowedVariableNodes } from "../../locals_list/locals_list_support";
@ -21,7 +20,6 @@ export function ThenElse(props: ThenElseParams) {
key={JSON.stringify(props.currentSequence)}
allowEmpty={true}
list={seqDropDown(props.resources)}
placeholder="Sequence..."
onChange={onChange}
selectedItem={selectedItem()} />
{!!calledSequenceVariableData &&

View file

@ -76,7 +76,7 @@ export async function fetchSyncData(dispatch: Function) {
get("Point", API.current.allPointsPath),
get("Sensor", API.current.sensorPath),
get("Tool", API.current.toolsPath),
get("Enigma", API.current.enigmaPath),
get("Alert", API.current.alertPath),
]),
2: () => Promise.all<{}>([
get("SensorReading", API.current.sensorReadingPath),

View file

@ -1,5 +1,4 @@
import * as React from "react";
import { Button, Classes, MenuItem, Alignment } from "@blueprintjs/core";
import { Select, ItemRenderer } from "@blueprintjs/select";
import { DropDownItem } from "./fb_select";
@ -33,7 +32,7 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
return <SelectComponent
{...flags}
items={this.props.items}
itemPredicate={this.filter}
itemPredicate={this.filter(this.props.items)}
itemRenderer={this.default}
noResults={<MenuItem disabled text={t("No results.")} />}
onItemSelect={this.handleValueChange}
@ -67,12 +66,13 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
text={`${i.label}`} />;
}
private filter(query: string, item: DropDownItem) {
if (item.heading) { return true; }
const itemHeadingId = item.headingId ? item.headingId : "";
const itemSearchLabel = `${itemHeadingId}: ${item.label}`;
return itemSearchLabel.toLowerCase().indexOf(query.toLowerCase()) >= 0;
}
private filter = (items: DropDownItem[]) =>
(query: string, item: DropDownItem): boolean => {
const matchedItems = allMatchedItems(items, query);
return item.heading
? sectionHasItems(item.headingId, matchedItems)
: isMatch(item, query);
}
private handleValueChange = (item: DropDownItem | undefined) => {
if (item) {
@ -82,3 +82,17 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
}
}
const isMatch = (item: DropDownItem, query: string): boolean =>
`${item.headingId || ""}: ${item.label}`
.toLowerCase().indexOf(query.toLowerCase()) >= 0;
const allMatchedItems =
(allItems: DropDownItem[], query: string): DropDownItem[] =>
allItems.filter(x => !x.heading).filter(x => isMatch(x, query));
const sectionHasItems =
(headingId: string | undefined, matchedItems: DropDownItem[]): boolean => {
const sectionItems = matchedItems.filter(x => x.headingId === headingId);
return sectionItems.length > 0;
};

View file

@ -12,8 +12,6 @@ export interface FBSelectProps {
list: DropDownItem[];
/** Allow user to select no value. */
allowEmpty?: boolean;
/** Text shown before user selection. */
placeholder?: string | undefined;
/** Extra class names to add. */
extraClass?: string;
/** Custom label for NULL_CHOICE instead of "None". */

View file

@ -8,10 +8,10 @@ def check_for_digests
.pluck(:device_id)
.uniq
.map do |id|
device = Device.find(id)
puts "Sending log digest to device \##{id} (#{device.name})"
LogDeliveryMailer.log_digest(device).deliver
end
device = Device.find(id)
puts "Sending log digest to device \##{id} (#{device.name})"
LogDeliveryMailer.log_digest(device).deliver
end
sleep 10.minutes
end
@ -38,7 +38,7 @@ def user_typed?(word)
end
namespace :api do
desc "Runs pending email digests. "\
desc "Runs pending email digests. " \
"Use the `FOREVER` ENV var to continually check."
task log_digest: :environment do
puts "Running log digest loop..."
@ -51,14 +51,13 @@ namespace :api do
end
def parcel(cmd, opts = " ")
intro = [ "node_modules/parcel-bundler/bin/cli.js",
cmd,
DashboardController::PARCEL_ASSET_LIST,
"--out-dir",
DashboardController::PUBLIC_OUTPUT_DIR,
"--public-url",
DashboardController::OUTPUT_URL,
].join(" ")
intro = ["node_modules/parcel-bundler/bin/cli.js",
cmd,
DashboardController::PARCEL_ASSET_LIST,
"--out-dir",
DashboardController::PUBLIC_OUTPUT_DIR,
"--public-url",
DashboardController::OUTPUT_URL].join(" ")
sh [intro, opts].join(" ")
end
@ -67,8 +66,7 @@ namespace :api do
# Clear out cache and previous builds on initial load.
sh ["rm -rf",
DashboardController::CACHE_DIR,
DashboardController::PUBLIC_OUTPUT_DIR
].join(" ")
DashboardController::PUBLIC_OUTPUT_DIR].join(" ")
parcel "watch", DashboardController::PARCEL_HMR_OPTS
end
@ -79,7 +77,7 @@ namespace :api do
desc "Reset _everything_, including your database"
task :reset do
puts "This is going to destroy _ALL_ of your local Farmbot SQL data and "\
puts "This is going to destroy _ALL_ of your local Farmbot SQL data and " \
"configs. Type 'destroy' to continue, enter to abort."
if user_typed?("destroy")
hard_reset_api
@ -88,38 +86,38 @@ namespace :api do
end
end
VERSION = "tag_name"
VERSION = "tag_name"
TIMESTAMP = "created_at"
desc "Update GlobalConfig to deprecate old FBOS versions"
task deprecate: :environment do
# Get current version
version_str = GlobalConfig.dump.fetch("FBOS_END_OF_LIFE_VERSION")
version_str = GlobalConfig.dump.fetch("FBOS_END_OF_LIFE_VERSION")
# Convert it to Gem::Version for easy comparisons (>, <, ==, etc)
current_version = Gem::Version::new(version_str)
# 60 days is the current policy.
cutoff = 60.days.ago
cutoff = 60.days.ago
# Download release data from github
stringio = open("https://api.github.com/repos/farmbot/farmbot_os/releases")
string = stringio.read
data = JSON
.parse(string)
.map { |x| x.slice(VERSION, TIMESTAMP) } # Only grab keys that matter
.reject { |x| x.fetch(VERSION).include?("-") } # Remove RC/Beta releases
.map do |x|
string = stringio.read
data = JSON
.parse(string)
.map { |x| x.slice(VERSION, TIMESTAMP) } # Only grab keys that matter
.reject { |x| x.fetch(VERSION).include?("-") } # Remove RC/Beta releases
.map do |x|
# Convert string-y version/timestamps to Real ObjectsTM
version = Gem::Version::new(x.fetch(VERSION).gsub("v", ""))
time = DateTime.parse(x.fetch(TIMESTAMP))
time = DateTime.parse(x.fetch(TIMESTAMP))
Pair.new(version, time)
end
.select do |pair|
.select do |pair|
# Grab versions that are > current version and outside of cutoff window
(pair.head > current_version) && (pair.tail < cutoff)
end
.sort_by { |p| p.tail } # Sort by release date
.last(2) # Grab 2 latest versions (closest to cuttof)
.first # Give 'em some leeway, grabbing the 2nd most outdated version.
.try(:head) # We might already be up-to-date?
.sort_by { |p| p.tail } # Sort by release date
.last(2) # Grab 2 latest versions (closest to cutoff)
.first # Give 'em some leeway, grabbing the 2nd most outdated version.
.try(:head) # We might already be up-to-date?
if data # ...or not
puts "Setting new support target to #{data.to_s}"
GlobalConfig # Set the new oldest support version.

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