diff --git a/Gemfile.lock b/Gemfile.lock index 422fbcd4d9..6bd053f870 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,7 +119,7 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.2) + faraday (2.13.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -188,7 +188,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - logger (1.6.6) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -222,18 +222,18 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.6-aarch64-linux-gnu) + nokogiri (1.18.7-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-x86_64-linux-gnu) + nokogiri (1.18.7-x86_64-linux-gnu) racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) - parser (3.3.7.3) + parser (3.3.7.4) ast (~> 2.4.1) racc - passenger (6.0.23) + passenger (6.0.27) rack (>= 1.6.13) - rackup + rackup (>= 1.0.1) rake (>= 12.3.3) pg (1.5.9) pry (0.15.2) @@ -306,7 +306,7 @@ GEM railties (>= 5.2) retriable (3.1.2) rexml (3.4.1) - rollbar (3.6.1) + rollbar (3.6.2) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) diff --git a/app/controllers/api/logs_controller.rb b/app/controllers/api/logs_controller.rb index a04e64edc5..86d1973ace 100644 --- a/app/controllers/api/logs_controller.rb +++ b/app/controllers/api/logs_controller.rb @@ -14,19 +14,27 @@ class LogsController < Api::AbstractController def search conf = current_device.web_app_config mt = CeleryScriptSettingsBag::ALLOWED_MESSAGE_TYPES - query = mt - .map { |x| "(type = '#{x}' AND verbosity <= ?)" } - .join(" OR ") - conditions = mt.map { |x| "#{x}_log" }.map { |x| conf.send(x) } - args_ = conditions.unshift(query) limit = current_device.max_log_count || Device::DEFAULT_MAX_LOGS - render json: current_device - .logs - .order(created_at: :desc) - .where(*args_) - .limit(limit) - .where(search_params) + # Build conditions once + type_conditions = mt.map.with_index do |type, i| + verbosity = conf.send("#{type}_log") + ["(type = ? AND verbosity <= ?)", type, verbosity] + end + + # Combine conditions efficiently + where_clause = type_conditions.map(&:first).join(" OR ") + where_values = type_conditions.flat_map { |c| c[1..-1] } + + # Single query with all conditions + logs = current_device + .logs + .order(created_at: :desc) + .limit(limit) + .where(where_clause, *where_values) + .where(search_params) + + render json: logs end def create diff --git a/app/controllers/api/sensor_readings_controller.rb b/app/controllers/api/sensor_readings_controller.rb index cd1f8b9b48..7e6f050e40 100644 --- a/app/controllers/api/sensor_readings_controller.rb +++ b/app/controllers/api/sensor_readings_controller.rb @@ -1,6 +1,5 @@ module Api class SensorReadingsController < Api::AbstractController - LIMIT = 2500 before_action :clean_old def create @@ -23,20 +22,14 @@ def destroy private def clean_old - if current_device.sensor_readings.count > LIMIT - current_device - .sensor_readings - .where - .not(id: readings.pluck(:id)) - .delete_all - end + current_device.delay.trim_excess_sensor_readings end def readings @readings ||= SensorReading .where(device: current_device) .order(created_at: :desc) - .limit(LIMIT) + .limit(Device::DEFAULT_MAX_SENSOR_READINGS) end def reading diff --git a/app/controllers/api/sequences_controller.rb b/app/controllers/api/sequences_controller.rb index 640a82a0fb..bb147cbadd 100644 --- a/app/controllers/api/sequences_controller.rb +++ b/app/controllers/api/sequences_controller.rb @@ -1,11 +1,28 @@ module Api class SequencesController < Api::AbstractController + include ActionController::Live + before_action :clean_expired_farm_events, only: [:destroy] def index - render json: sequences - .to_a - .map { |s| Sequences::Show.run!(sequence: s) } + # Stream to reduce memory usage + response.headers['Content-Type'] = 'application/json' + response.headers['Cache-Control'] = 'no-cache' + response.stream.write '[' + + Sequence.where(device: current_device) + .includes(:sequence_publication, :sequence_version) + .each_with_index do |s, index| + # Load the sequence with all needed associations and convert to JSON + seq_json = Sequences::Show.run!(sequence: Sequence.with_usage_reports.find(s.id)).to_json + # Append a comma for all but the first element to maintain valid JSON syntax + response.stream.write ',' unless index.zero? + response.stream.write seq_json + end + + response.stream.write ']' + ensure + response.stream.close end def show @@ -65,12 +82,9 @@ def sequence_params @sequence_params ||= raw_json[:sequence] || raw_json || {} end - def sequences - @sequences ||= Sequence.with_usage_reports.where(device: current_device) - end - + # Retrieve a single sequence record directly associated with the current device def sequence - @sequence ||= sequences.find(params[:id]) + @sequence ||= Sequence.with_usage_reports.find_by!(id: params[:id], device: current_device) end end end diff --git a/app/models/device.rb b/app/models/device.rb index a7830f3e70..05f708f262 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -4,6 +4,7 @@ class Device < ApplicationRecord DEFAULT_MAX_IMAGES = 100 DEFAULT_MAX_LOGS = 1000 DEFAULT_MAX_TELEMETRY = 300 + DEFAULT_MAX_SENSOR_READINGS = 2500 DEFAULT_MAX_LOG_AGE_IN_DAYS = 60 DEFAULT_MAX_SEQUENCE_COUNT = 75 DEFAULT_MAX_SEQUENCE_LENGTH = 30 @@ -120,6 +121,24 @@ def trim_excess_telemetry excess_telemetry.delete_all end + # Give the user back the amount of sensor readings they are allowed to view. + def limited_sensor_readings_list + sensor_readings + .order(created_at: :desc) + .limit(DEFAULT_MAX_SENSOR_READINGS) + end + + def excess_sensor_readings + sensor_readings + .where + .not(id: limited_sensor_readings_list.pluck(:id)) + .where(device_id: self.id) + end + + def trim_excess_sensor_readings + excess_sensor_readings.delete_all + end + def self.current RequestStore.store[:device] end diff --git a/app/models/sequence.rb b/app/models/sequence.rb index 70fc89a7d2..188b957b3b 100644 --- a/app/models/sequence.rb +++ b/app/models/sequence.rb @@ -23,6 +23,8 @@ class Sequence < ApplicationRecord has_many :regimen_items has_many :primary_nodes, dependent: :destroy has_many :edge_nodes, dependent: :destroy + has_one :sequence_publication, foreign_key: :author_sequence_id + belongs_to :sequence_version, optional: true # allowable label colors for the frontend. [:name, :kind].each { |n| validates n, presence: true } diff --git a/app/mutations/sequences/show.rb b/app/mutations/sequences/show.rb index 20df4c6d7b..be8c22c5b7 100644 --- a/app/mutations/sequences/show.rb +++ b/app/mutations/sequences/show.rb @@ -63,7 +63,11 @@ def copyright end def sequence_publication - @sequence_publication ||= SequencePublication.find_by(author_sequence_id: sequence.id) + # Cache the association if it's already loaded + return @sequence_publication if defined?(@sequence_publication) + @sequence_publication = sequence.association(:sequence_publication).loaded? ? + sequence.sequence_publication : + SequencePublication.find_by(author_sequence_id: sequence.id) end def description @@ -71,7 +75,11 @@ def description end def sequence_version - @sequence_version ||= SequenceVersion.find_by(id: sequence_version_id) + # Cache the association if it's already loaded + return @sequence_version if defined?(@sequence_version) + @sequence_version = sequence.association(:sequence_version).loaded? ? + sequence.sequence_version : + SequenceVersion.find_by(id: sequence_version_id) end def use_upstream_version? @@ -90,7 +98,7 @@ def available_version_ids # If it is not published, don't show anything to the author. # If it IS published, show the versions to the author. if is_owner? - return sequence_publication.sequence_versions.pluck(:id) + return sequence_publication&.sequence_versions&.pluck(:id) || [] end # Second attempt: diff --git a/frontend/__test_support__/additional_mocks.tsx b/frontend/__test_support__/additional_mocks.tsx index 43577a2752..0b0bd69bea 100644 --- a/frontend/__test_support__/additional_mocks.tsx +++ b/frontend/__test_support__/additional_mocks.tsx @@ -47,6 +47,7 @@ jest.mock("react-router", () => ({ Route: jest.fn(({ children }) =>
{children}
), Routes: jest.fn(({ children }) =>
{children}
), useNavigate: () => mockNavigate, + useLocation: () => window.location, Navigate: ({ to }: { to: string }) =>
{mockNavigate(to)}
, Outlet: jest.fn(() =>
), })); diff --git a/frontend/controls/move/__tests__/jog_buttons_test.tsx b/frontend/controls/move/__tests__/jog_buttons_test.tsx index 628993088c..231b28f873 100644 --- a/frontend/controls/move/__tests__/jog_buttons_test.tsx +++ b/frontend/controls/move/__tests__/jog_buttons_test.tsx @@ -95,6 +95,7 @@ describe("", () => { const fakeProps = (): PowerAndResetMenuProps => ({ botOnline: true, showAdvanced: true, + dispatch: jest.fn(), }); it("restarts firmware", () => { diff --git a/frontend/controls/move/bot_position_rows.tsx b/frontend/controls/move/bot_position_rows.tsx index d3a4d07e24..4e4f9e8387 100644 --- a/frontend/controls/move/bot_position_rows.tsx +++ b/frontend/controls/move/bot_position_rows.tsx @@ -27,6 +27,7 @@ import { import { NumberConfigKey } from "farmbot/dist/resources/configs/firmware"; import { isUndefined } from "lodash"; import { calculateScale } from "../../settings/hardware_settings"; +import { setPanelOpen } from "../../farm_designer/panel_header"; export const BotPositionRows = (props: BotPositionRowsProps) => { const { locationData, getConfigValue, arduinoBusy, locked } = props; @@ -141,7 +142,10 @@ export const AxisActions = (props: AxisActionsProps) => { onClick={setAxisLength({ axis, dispatch, botPosition, sourceFwConfig })}> {t("SET LENGTH")} - { navigate(Path.settings("axes")); }}> + { + dispatch(setPanelOpen(true)); + navigate(Path.settings("axes")); + }}> {t("Settings")} diff --git a/frontend/controls/move/jog_buttons.tsx b/frontend/controls/move/jog_buttons.tsx index cc16ff2726..3e9f3cb2d8 100644 --- a/frontend/controls/move/jog_buttons.tsx +++ b/frontend/controls/move/jog_buttons.tsx @@ -97,7 +97,9 @@ export class JogButtons target={
; }; diff --git a/frontend/devices/connectivity/__tests__/diagnosis_test.tsx b/frontend/devices/connectivity/__tests__/diagnosis_test.tsx index 5c4bbfb889..6e713c4e8b 100644 --- a/frontend/devices/connectivity/__tests__/diagnosis_test.tsx +++ b/frontend/devices/connectivity/__tests__/diagnosis_test.tsx @@ -17,6 +17,7 @@ describe("", () => { botAPI: true, botFirmware: true, }, + dispatch: jest.fn(), }); it("renders help text", () => { diff --git a/frontend/devices/connectivity/connectivity.tsx b/frontend/devices/connectivity/connectivity.tsx index 592bbf31ff..32ae7f5d59 100644 --- a/frontend/devices/connectivity/connectivity.tsx +++ b/frontend/devices/connectivity/connectivity.tsx @@ -114,7 +114,7 @@ export class Connectivity : undefined} hover={this.hover} hoveredConnection={this.state.hoveredConnection} />)} - + {this.props.flags.userAPI && this.props.flags.userMQTT && this.props.flags.botAPI && this.props.flags.botMQTT && this.props.apiFirmwareValue @@ -162,7 +162,11 @@ export class Connectivity - + {t("Learn more about ports")} diff --git a/frontend/devices/connectivity/diagnosis.tsx b/frontend/devices/connectivity/diagnosis.tsx index ecc9343aa2..2fba974667 100644 --- a/frontend/devices/connectivity/diagnosis.tsx +++ b/frontend/devices/connectivity/diagnosis.tsx @@ -8,6 +8,7 @@ import { SyncStatus } from "farmbot"; import { syncText } from "../../nav/sync_text"; import { useNavigate } from "react-router"; import { linkToSetting } from "../../settings/maybe_highlight"; +import { setPanelOpen } from "../../farm_designer/panel_header"; export type ConnectionName = | "userAPI" @@ -20,6 +21,7 @@ export type ConnectionStatusFlags = Record; export interface DiagnosisProps { statusFlags: ConnectionStatusFlags; hideGraphic?: boolean; + dispatch: Function; } export interface DiagnosisSaucerProps extends ConnectionStatusFlags { className?: string; @@ -61,7 +63,10 @@ export function Diagnosis(props: DiagnosisProps) {

{t("Always")}  { navigate(linkToSetting(DeviceSetting.farmbotOS)); }}> + onClick={() => { + props.dispatch(setPanelOpen(true)); + navigate(linkToSetting(DeviceSetting.farmbotOS)); + }}> {t("upgrade FarmBot OS")}  {t("before troubleshooting.")} @@ -69,11 +74,19 @@ export function Diagnosis(props: DiagnosisProps) {

{diagnosisMessage(getDiagnosisCode(props.statusFlags))}

- + {t("Click here to learn more about connectivity codes.")} - + {t("Click here for document to show to your IT department.")} diff --git a/frontend/devices/connectivity/qos_panel.tsx b/frontend/devices/connectivity/qos_panel.tsx index a2012a2f51..16d7ddfc58 100644 --- a/frontend/devices/connectivity/qos_panel.tsx +++ b/frontend/devices/connectivity/qos_panel.tsx @@ -88,7 +88,11 @@ export class QosPanel extends React.Component { + docLinkClick({ + slug: "connecting-farmbot-to-the-internet", + navigate: this.navigate, + dispatch: this.props.dispatch, + })}> {t("Learn more about connecting")} diff --git a/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx b/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx index 87f0c9dc8c..2a5e018783 100644 --- a/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx +++ b/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx @@ -6,7 +6,7 @@ import React from "react"; import { shallow, mount } from "enzyme"; import { Bugs, BugsProps, showBugResetButton, showBugs, resetBugs, BugsControls, - ExtraSettings, + BugsSettings, } from "../bugs"; import { EggKeys, setEggStatus, getEggStatus } from "../status"; import { range } from "lodash"; @@ -112,10 +112,10 @@ describe("", () => { }); }); -describe("", () => { +describe("", () => { it("toggles setting on", () => { localStorage.setItem(EggKeys.BRING_ON_THE_BUGS, ""); - const wrapper = mount(
{ExtraSettings("surprise")}
); + const wrapper = mount(); expect(wrapper.text().toLowerCase()).toContain("bug"); wrapper.find("button").last().simulate("click"); expect(localStorage.getItem(EggKeys.BRING_ON_THE_BUGS)).toEqual("true"); @@ -123,7 +123,7 @@ describe("", () => { it("toggles setting off", () => { localStorage.setItem(EggKeys.BRING_ON_THE_BUGS, "true"); - const wrapper = mount(
{ExtraSettings("surprise")}
); + const wrapper = mount(); expect(wrapper.text().toLowerCase()).toContain("bug"); wrapper.find("button").last().simulate("click"); expect(localStorage.getItem(EggKeys.BRING_ON_THE_BUGS)).toEqual(""); diff --git a/frontend/farm_designer/map/easter_eggs/bugs.tsx b/frontend/farm_designer/map/easter_eggs/bugs.tsx index 98f7359796..cd0698c762 100644 --- a/frontend/farm_designer/map/easter_eggs/bugs.tsx +++ b/frontend/farm_designer/map/easter_eggs/bugs.tsx @@ -6,7 +6,6 @@ import { getEggStatus, setEggStatus, EggKeys } from "./status"; import { t } from "../../../i18next_wrapper"; import { Row, ToggleButton } from "../../../ui"; import { BUGS, FilePath, Bug as BugSlug } from "../../../internal_urls"; -import { showByEveryTerm } from "../../../settings"; export interface BugsProps { mapTransformProps: MapTransformProps; @@ -133,19 +132,28 @@ export const BugsControls = () => :
; -const Setting = (title: string, key: string, value: string) => { - const on = localStorage.getItem(key) == value; +interface SettingProps { + title: string; + storageKey: string; + value: string; +} + +const Setting = (props: SettingProps) => { + const { title, storageKey, value } = props; + const on = localStorage.getItem(storageKey) == value; return localStorage.setItem(key, on ? "" : value)} /> + toggleAction={() => localStorage.setItem(storageKey, on ? "" : value)} /> ; }; -export const ExtraSettings = (searchTerm: string) => { - return showByEveryTerm("surprise", searchTerm) && -
- {Setting("Bug Attack", EggKeys.BRING_ON_THE_BUGS, "true")} -
; +export const BugsSettings = () => { + return
+ +
; }; diff --git a/frontend/farmware/farmware_forms.tsx b/frontend/farmware/farmware_forms.tsx index 7634257e90..459174bb7b 100644 --- a/frontend/farmware/farmware_forms.tsx +++ b/frontend/farmware/farmware_forms.tsx @@ -188,7 +188,8 @@ export class FarmwareForm const collapsedConfigs = farmware.config.filter(collapsed); return
- {this.props.docPage && } + {this.props.docPage && + } - +
; } diff --git a/frontend/help/documentation.tsx b/frontend/help/documentation.tsx index c61e763469..e66753c1cd 100644 --- a/frontend/help/documentation.tsx +++ b/frontend/help/documentation.tsx @@ -3,19 +3,26 @@ import { DesignerPanel, DesignerPanelContent, } from "../farm_designer/designer_panel"; import { Panel } from "../farm_designer/panel_header"; -import { getUrlQuery } from "../util"; import { HelpHeader } from "./header"; +import { useLocation } from "react-router"; export interface DocumentationPanelProps { url: string; } export const DocumentationPanel = (props: DocumentationPanelProps) => { - const page = getUrlQuery("page"); + const location = useLocation(); + const [src, setSrc] = React.useState(""); + + React.useEffect(() => { + const page = new URLSearchParams(location.search).get("page"); + setSrc(page ? `${props.url}/${page}` : props.url); + }, [props, location]); + return -