From 8705977e27fd4f33e11d1cbade0bdf0f710bc528 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 17 Sep 2025 09:24:58 -0700 Subject: [PATCH 01/27] combine jobs and logs tabs --- frontend/css/panels/jobs.scss | 49 +++++++++++++++++++++--- frontend/css/panels/logs.scss | 36 +++++++++-------- frontend/devices/__tests__/jobs_test.tsx | 30 ++------------- frontend/devices/jobs.tsx | 22 +++-------- frontend/logs/__tests__/index_test.tsx | 2 + frontend/logs/components/logs_table.tsx | 41 ++++++++++++-------- frontend/logs/index.tsx | 2 +- 7 files changed, 101 insertions(+), 81 deletions(-) diff --git a/frontend/css/panels/jobs.scss b/frontend/css/panels/jobs.scss index 8ad9210965..01f741f2bd 100644 --- a/frontend/css/panels/jobs.scss +++ b/frontend/css/panels/jobs.scss @@ -2,7 +2,48 @@ @use "sass:color"; .jobs-and-logs { - margin-top: 1rem; + grid-template-rows: 10rem 25rem; + height: 35.5rem; + + .jobs-section, + .logs-section { + display: flex; + flex-direction: column; + overflow: hidden; + } + + .jobs-section { + border-bottom: 2px solid var(--border-color); + .jobs-tab { + flex: 1; + overflow-y: auto; + } + } + + .logs-section { + .logs-tab { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + .search-row { + margin: 0 1rem; + } + + .logs-table-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + + .logs-table { + max-height: none; + height: 100%; + } + } + } + } } .jobs-panel { @@ -16,8 +57,8 @@ } .jobs-tab { - overflow-y: scroll; - max-height: 26rem; + overflow-y: auto; + height: 100%; &.bp6-popover { margin-top: 1.5rem; } @@ -34,7 +75,6 @@ position: sticky; top: 0; z-index: 999; - background: var(--main-bg); } tr { transform: scale(1); @@ -72,6 +112,5 @@ width: min(500px, 100vw - 1rem); max-height: calc(100vh - 10rem); overflow: hidden; - padding-top: 1rem; } } diff --git a/frontend/css/panels/logs.scss b/frontend/css/panels/logs.scss index 2ef734e984..f7b85c70d6 100644 --- a/frontend/css/panels/logs.scss +++ b/frontend/css/panels/logs.scss @@ -6,10 +6,9 @@ .fa-trash { display: none; } - tr { - vertical-align: top; + tbody tr { &:hover { - background: $translucent2_white; + background: var(--secondary-bg); .fa-trash { display: inline; margin-left: 6px; @@ -21,6 +20,9 @@ } } } + tr { + vertical-align: top; + } .fa-filter { border: 1px black solid; border-radius: 50%; @@ -52,12 +54,6 @@ font-weight: bold; } } - .notice { - font-style: italic; - text-align: center; - font-size: 1.4rem; - padding: 1rem; - } } .logs-filter-menu { @@ -93,6 +89,8 @@ display: block; overflow: scroll; max-height: 42rem; + padding-top: 0.5rem; + border-radius: 0; .log-verbosity-saucer .saucer { text-align: center; margin-left: 6px; @@ -103,15 +101,11 @@ button { float: none; } + table { + border-radius: 0; + } thead { text-align: left; - background: var(--main-bg); - } - thead, - th { - position: sticky; - top: 0; - z-index: 999; } td { word-break: break-word; @@ -132,6 +126,16 @@ td:nth-child(4) { white-space: nowrap; } + + .logs-retention-row { + td { + font-style: italic; + text-align: center; + padding: 0.7rem 1rem 1rem; + white-space: normal; + background: var(--secondary-bg); + } + } } .link-to-logs { diff --git a/frontend/devices/__tests__/jobs_test.tsx b/frontend/devices/__tests__/jobs_test.tsx index b26a830cc7..b251e0d94b 100644 --- a/frontend/devices/__tests__/jobs_test.tsx +++ b/frontend/devices/__tests__/jobs_test.tsx @@ -10,7 +10,6 @@ import { fakeBytesJob, fakePercentJob } from "../../__test_support__/fake_bot_da import { fakeTimeSettings } from "../../__test_support__/fake_time_settings"; import { bot } from "../../__test_support__/fake_state/bot"; import { jobsState } from "../../__test_support__/panel_state"; -import { Actions } from "../../constants"; import { fakeDevice } from "../../__test_support__/resource_index_builder"; describe("", () => { @@ -50,31 +49,10 @@ describe("", () => { device: fakeDevice(), }); - it("renders jobs", () => { - const p = fakeProps(); - p.jobsPanelState.jobs = true; - p.jobsPanelState.logs = false; - const wrapper = mount(); - expect(wrapper.html()).toContain("jobs-tab"); - expect(wrapper.html()).not.toContain("logs-tab"); - }); - - it("renders logs", () => { - const p = fakeProps(); - p.jobsPanelState.jobs = false; - p.jobsPanelState.logs = true; - const wrapper = mount(); - expect(wrapper.html()).not.toContain("jobs-tab"); - expect(wrapper.html()).toContain("logs-tab"); - }); - - it("sets state", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.instance().setPanelState("logs")(); - expect(p.dispatch).toHaveBeenCalledWith({ - type: Actions.SET_JOBS_PANEL_OPTION, payload: "logs", - }); + it("renders jobs and logs", () => { + const wrapper = mount(); + expect(wrapper.find(".jobs-tab").length).toEqual(1); + expect(wrapper.find(".logs-tab").length).toEqual(1); }); }); diff --git a/frontend/devices/jobs.tsx b/frontend/devices/jobs.tsx index 241365fda1..bf3112bd2e 100644 --- a/frontend/devices/jobs.tsx +++ b/frontend/devices/jobs.tsx @@ -15,7 +15,6 @@ import moment from "moment"; import { betterCompact, formatTime } from "../util"; import { Color } from "../ui"; import { cloneDeep, round, sortBy } from "lodash"; -import { Actions } from "../constants"; import { BotState, SourceFbosConfig } from "./interfaces"; import { GetWebAppConfigValue } from "../config_storage/actions"; import { LogsPanel } from "../logs"; @@ -62,12 +61,6 @@ export interface JobsAndLogsProps { export class JobsAndLogs extends React.Component { - setPanelState = (key: keyof JobsAndLogsState) => () => - this.props.dispatch({ - type: Actions.SET_JOBS_PANEL_OPTION, - payload: key, - }); - Jobs = () => { return
-
- - + return
+
+ +
+
+
- {jobs && } - {logs && }
; } } diff --git a/frontend/logs/__tests__/index_test.tsx b/frontend/logs/__tests__/index_test.tsx index 1bb4e0911c..19c807369d 100644 --- a/frontend/logs/__tests__/index_test.tsx +++ b/frontend/logs/__tests__/index_test.tsx @@ -53,6 +53,8 @@ describe("", () => { .map(string => expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase())); verifyFilterState(wrapper, true); + expect(wrapper.find(".logs-retention-row").text().toLowerCase()) + .toContain("logs older than"); }); it("handles unknown log type", () => { diff --git a/frontend/logs/components/logs_table.tsx b/frontend/logs/components/logs_table.tsx index 0079b91917..1deba7f682 100644 --- a/frontend/logs/components/logs_table.tsx +++ b/frontend/logs/components/logs_table.tsx @@ -96,6 +96,20 @@ const LOG_TABLE_CLASS = [ /** All log messages with select data in table form for display in the app. */ export const LogsTable = (props: LogsTableProps) => { + const retentionDays = props.device.body.max_log_age_in_days || 60; + const rows = filterByVerbosity(getFilterLevel(props.state), props.logs) + .filter(bySearchTerm(props.state.searchTerm, props.timeSettings)) + .filter(log => !props.state.currentFbosOnly || !props.fbosVersion || + logVersionMatch(log, props.fbosVersion)) + .map((log: TaggedLog) => + ); + return
@@ -107,25 +121,18 @@ export const LogsTable = (props: LogsTableProps) => { - {filterByVerbosity(getFilterLevel(props.state), props.logs) - .filter(bySearchTerm(props.state.searchTerm, props.timeSettings)) - .filter(log => !props.state.currentFbosOnly || !props.fbosVersion || - logVersionMatch(log, props.fbosVersion)) - .map((log: TaggedLog) => - )} + {rows} + + + + +
+ {t("Logs older than {{ days }} days are automatically deleted", { + days: retentionDays, + })} +
-

- {t("Logs older than {{ days }} days are automatically deleted", { - days: props.device.body.max_log_age_in_days || 60, - })} -

; }; diff --git a/frontend/logs/index.tsx b/frontend/logs/index.tsx index b6961bb94f..b8b9dd6d9e 100644 --- a/frontend/logs/index.tsx +++ b/frontend/logs/index.tsx @@ -96,7 +96,7 @@ export class LogsPanel extends React.Component> { render() { const { dispatch, bot } = this.props; - return
+ return
Date: Wed, 17 Sep 2025 09:58:03 -0700 Subject: [PATCH 02/27] table height adjustments --- frontend/css/panels/jobs.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/css/panels/jobs.scss b/frontend/css/panels/jobs.scss index 01f741f2bd..e33022aed3 100644 --- a/frontend/css/panels/jobs.scss +++ b/frontend/css/panels/jobs.scss @@ -2,8 +2,8 @@ @use "sass:color"; .jobs-and-logs { - grid-template-rows: 10rem 25rem; - height: 35.5rem; + grid-template-rows: 14rem 23rem; + height: 37.5rem; .jobs-section, .logs-section { @@ -64,6 +64,7 @@ } table { text-align: left; + border-radius: 0; p { padding: 1rem; } From 596b7be2c6b6db1b67dff36c8cf4b095cf170073 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 17 Sep 2025 11:09:17 -0700 Subject: [PATCH 03/27] add option to demo the app to login page --- frontend/demo/demo_iframe.tsx | 30 ++++++----- .../__tests__/demo_login_option_test.tsx | 53 +++++++++++++++++++ frontend/front_page/demo_login_option.tsx | 36 +++++++++++++ frontend/front_page/front_page.tsx | 16 ++++-- 4 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 frontend/front_page/__tests__/demo_login_option_test.tsx create mode 100644 frontend/front_page/demo_login_option.tsx diff --git a/frontend/demo/demo_iframe.tsx b/frontend/demo/demo_iframe.tsx index 9d370c64fd..93f9fb1e8f 100644 --- a/frontend/demo/demo_iframe.tsx +++ b/frontend/demo/demo_iframe.tsx @@ -9,7 +9,7 @@ import { Path } from "../internal_urls"; import { FBSelect } from "../ui"; import { SEED_DATA_OPTIONS, SEED_DATA_OPTIONS_DDI } from "../messages/cards"; -interface State { +export interface DemoAccountState { error: Error | undefined; stage: string; productLine: string; @@ -27,8 +27,8 @@ export const EASTER_EGG = "BIRDS AREN'T REAL"; export const WAITING_ON_API = "Planting your demo garden..."; // APPLICATION CODE ============================== -export class DemoIframe extends React.Component<{}, State> { - state: State = { +export abstract class DemoAccountBase

extends React.Component { + state: DemoAccountState = { error: undefined, stage: t("DEMO THE APP"), productLine: "genesis_1.8", @@ -69,6 +69,20 @@ export class DemoIframe extends React.Component<{}, State> { this.connectMqtt().then(this.connectApi); }; + protected abstract ok(): React.ReactNode; + + no = () => { + console.error(this.state.error); + const message = JSON.stringify(this.state.error, undefined, 2); + return

{message}: {"" + this.state.error}
; + }; + + render() { + return this.state.error ? this.no() : this.ok(); + } +} + +export class DemoIframe extends DemoAccountBase { ok = () => { const selection = this.state.productLine; return
@@ -90,14 +104,4 @@ export class DemoIframe extends React.Component<{}, State> { onChange={ddi => this.setState({ productLine: "" + ddi.value })} />
; }; - - no = () => { - console.error(this.state.error); - const message = JSON.stringify(this.state.error, undefined, 2); - return
{message}: {"" + this.state.error}
; - }; - - render() { - return this.state.error ? this.no() : this.ok(); - } } diff --git a/frontend/front_page/__tests__/demo_login_option_test.tsx b/frontend/front_page/__tests__/demo_login_option_test.tsx new file mode 100644 index 0000000000..c203832088 --- /dev/null +++ b/frontend/front_page/__tests__/demo_login_option_test.tsx @@ -0,0 +1,53 @@ +let mockResponse: string | Error = "12345"; +jest.mock("axios", () => ({ + post: jest.fn(() => + typeof mockResponse === "string" + ? Promise.resolve(mockResponse) + : Promise.reject(mockResponse)), +})); + +const mockMqttClient = { + on: jest.fn((ev: string, cb: Function) => ev == "connect" && cb()), + subscribe: jest.fn(), +}; + +jest.mock("mqtt", () => ({ connect: () => mockMqttClient })); + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DemoLoginOption } from "../demo_login_option"; +import axios from "axios"; +import { MQTT_CHAN } from "../../demo/demo_iframe"; + +describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders demo controls", () => { + mockResponse = "ok"; + render(); + expect(screen.getByRole("heading", { name: /demo the app/i })) + .toBeInTheDocument(); + expect(screen.getByRole("button", { name: /demo the app/i })) + .toBeInTheDocument(); + expect(screen.getByText(/farmbot model/i)).toBeInTheDocument(); + }); + + it("requests a demo account on click", async () => { + mockResponse = "ok"; + + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /demo the app/i })); + + await waitFor(() => + expect(mockMqttClient.subscribe) + .toHaveBeenCalledWith(MQTT_CHAN, expect.any(Function))); + await waitFor(() => + expect(axios.post).toHaveBeenCalledWith( + "/api/demo_account", + expect.objectContaining({ product_line: expect.any(String) }))); + }); +}); diff --git a/frontend/front_page/demo_login_option.tsx b/frontend/front_page/demo_login_option.tsx new file mode 100644 index 0000000000..9503ae2477 --- /dev/null +++ b/frontend/front_page/demo_login_option.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { DemoAccountBase } from "../demo/demo_iframe"; +import { t } from "../i18next_wrapper"; +import { FBSelect, Widget, WidgetBody, WidgetHeader } from "../ui"; +import { SEED_DATA_OPTIONS, SEED_DATA_OPTIONS_DDI } from "../messages/cards"; + +export class DemoLoginOption extends DemoAccountBase { + ok = () => { + const selection = this.state.productLine; + return + + +
+
+ + x.value != "none")} + customNullLabel={t("Select a model")} + selectedItem={SEED_DATA_OPTIONS_DDI()[selection]} + onChange={ddi => this.setState({ productLine: "" + ddi.value })} /> +
+
+ +
+
+
+
; + }; +} diff --git a/frontend/front_page/front_page.tsx b/frontend/front_page/front_page.tsx index 64d6d10745..c5d8c36445 100644 --- a/frontend/front_page/front_page.tsx +++ b/frontend/front_page/front_page.tsx @@ -16,9 +16,17 @@ import { get } from "lodash"; import { t } from "../i18next_wrapper"; import { ToastContainer } from "../toast/fb_toast"; import { Path } from "../internal_urls"; +import { DemoLoginOption } from "./demo_login_option"; export const DEFAULT_APP_PAGE = Path.app(); +const OrDivider = () => +
+
+ {t("OR")} +
+
; + export interface PartialFormEvent { currentTarget: { checked: boolean; @@ -218,11 +226,7 @@ export class FrontPage extends React.Component<{}, Partial> {

{t("Setup, customize, and control your garden from anywhere")}

-
-
- {t("OR")} -
-
+ > { set={(key, val) => this.setState({ [key]: val })}> {this.maybeRenderTos()} + +
; From d62cf066fec1ee443be2d0463833ba1591c0d84f Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 17 Sep 2025 14:03:27 -0700 Subject: [PATCH 04/27] minor style improvements --- frontend/css/app/navbar.scss | 1 - frontend/css/app/status_ticker.scss | 4 ---- frontend/css/farm_designer/farm_designer.scss | 6 +++--- frontend/css/farm_designer/farm_designer_panels.scss | 2 +- frontend/css/panels/jobs.scss | 7 ++----- frontend/css/panels/logs.scss | 6 +++--- frontend/logs/index.tsx | 2 +- frontend/nav/index.tsx | 2 +- 8 files changed, 11 insertions(+), 19 deletions(-) diff --git a/frontend/css/app/navbar.scss b/frontend/css/app/navbar.scss index d041fc42d0..ee9f0006c1 100644 --- a/frontend/css/app/navbar.scss +++ b/frontend/css/app/navbar.scss @@ -11,7 +11,6 @@ } nav { - margin-top: 3rem; button { margin: 1.8rem 1.8rem 0 0; font-size: 1.3rem !important; diff --git a/frontend/css/app/status_ticker.scss b/frontend/css/app/status_ticker.scss index 3971dbd4bf..169b25fcb2 100644 --- a/frontend/css/app/status_ticker.scss +++ b/frontend/css/app/status_ticker.scss @@ -2,10 +2,6 @@ @use "sass:color"; .ticker-list { - position: fixed; - top: 0; - left: 0; - right: 0; z-index: 3; background: var(--main-bg); backdrop-filter: var(--blur); diff --git a/frontend/css/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss index cd3f8bd42b..ce6a082ee6 100644 --- a/frontend/css/farm_designer/farm_designer.scss +++ b/frontend/css/farm_designer/farm_designer.scss @@ -14,7 +14,7 @@ } &.panel-closed-mobile, &.panel-closed { - top: 15rem; + top: 13rem; } } } @@ -535,8 +535,8 @@ } .garden-map-legend { - position: fixed; - top: 8rem; + position: absolute; + top: 7.5rem; right: -155px; z-index: 3; transition: all 0.3s ease; diff --git a/frontend/css/farm_designer/farm_designer_panels.scss b/frontend/css/farm_designer/farm_designer_panels.scss index e4ae09c268..2304379b5e 100644 --- a/frontend/css/farm_designer/farm_designer_panels.scss +++ b/frontend/css/farm_designer/farm_designer_panels.scss @@ -2,7 +2,7 @@ @use "sass:color"; .farm-designer-panels { - position: fixed; + position: absolute; top: 7.5rem; width: 45rem; margin: 1rem; diff --git a/frontend/css/panels/jobs.scss b/frontend/css/panels/jobs.scss index e33022aed3..ba26d4435d 100644 --- a/frontend/css/panels/jobs.scss +++ b/frontend/css/panels/jobs.scss @@ -26,17 +26,13 @@ flex-direction: column; flex: 1; overflow: hidden; - .search-row { margin: 0 1rem; } - .logs-table-wrapper { flex: 1; display: flex; flex-direction: column; - overflow: hidden; - .logs-table { max-height: none; height: 100%; @@ -79,6 +75,7 @@ } tr { transform: scale(1); + vertical-align: top; } th, td { @@ -110,7 +107,7 @@ .jobs-panel-portal { .bp6-popover-content { padding: 0; - width: min(500px, 100vw - 1rem); + width: min(500px, 100vw); max-height: calc(100vh - 10rem); overflow: hidden; } diff --git a/frontend/css/panels/logs.scss b/frontend/css/panels/logs.scss index f7b85c70d6..36c8648452 100644 --- a/frontend/css/panels/logs.scss +++ b/frontend/css/panels/logs.scss @@ -3,6 +3,7 @@ .logs-table-wrapper { border: none; + overflow: scroll; .fa-trash { display: none; } @@ -86,10 +87,9 @@ } .logs-table { - display: block; - overflow: scroll; + display: table; max-height: 42rem; - padding-top: 0.5rem; + margin-top: 0.5rem; border-radius: 0; .log-verbosity-saucer .saucer { text-align: center; diff --git a/frontend/logs/index.tsx b/frontend/logs/index.tsx index b8b9dd6d9e..0dc9adb23f 100644 --- a/frontend/logs/index.tsx +++ b/frontend/logs/index.tsx @@ -97,7 +97,7 @@ export class LogsPanel extends React.Component> { render() { const { dispatch, bot } = this.props; return
-
+
> { this.isStaff ? "red" : "", ].join(" ")}>