log verbosity filters

pull/561/head
gabrielburnworth 2017-12-11 03:50:41 -08:00
parent 237ddb2651
commit 5f0055f084
12 changed files with 303 additions and 59 deletions

View File

@ -2,6 +2,28 @@ jest.mock("react-redux", () => ({
connect: jest.fn()
}));
const mockStorj: Dictionary<number | boolean> = {};
jest.mock("../../session", () => {
return {
Session: {
getNum: (k: string) => {
return mockStorj[k];
},
setNum: (k: string, v: number) => {
mockStorj[k] = v;
},
getBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
}
},
// tslint:disable-next-line:no-any
safeNumericSetting: (x: any) => x
};
});
import * as React from "react";
import { mount } from "enzyme";
import { Logs } from "../index";
@ -10,6 +32,8 @@ import { TaggedLog, SpecialStatus } from "../../resources/tagged_resources";
import { Log } from "../../interfaces";
import { generateUuid } from "../../resources/util";
import { bot } from "../../__test_support__/fake_state/bot";
import { Dictionary } from "farmbot";
import { NumericSetting } from "../../session_keys";
describe("<Logs />", () => {
function fakeLogs(): TaggedLog[] {
@ -48,13 +72,13 @@ describe("<Logs />", () => {
.map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
const filterBtn = wrapper.find("button").first();
expect(filterBtn.text().toLowerCase()).toEqual("filter");
expect(filterBtn.hasClass("gray")).toBeTruthy();
expect(filterBtn.text().toLowerCase()).toEqual("filters active");
expect(filterBtn.hasClass("green")).toBeTruthy();
});
it("filters logs", () => {
const wrapper = mount(<Logs logs={fakeLogs()} bot={bot} />);
wrapper.setState({ info: false });
wrapper.setState({ info: 0 });
expect(wrapper.text()).not.toContain("Fake log message 1");
const filterBtn = wrapper.find("button").first();
expect(filterBtn.text().toLowerCase()).toEqual("filters active");
@ -74,8 +98,46 @@ describe("<Logs />", () => {
it("shows verbosity", () => {
const logs = fakeLogs();
logs[0].body.meta.verbosity = 999;
logs[0].body.meta.verbosity = -999;
const wrapper = mount(<Logs logs={logs} bot={bot} />);
expect(wrapper.text()).toContain(999);
expect(wrapper.text()).toContain(-999);
});
it("loads filter setting", () => {
mockStorj[NumericSetting.warnLog] = 3;
const wrapper = mount(<Logs logs={fakeLogs()} bot={bot} />);
expect(wrapper.state().warn).toEqual(3);
});
it("shows overall filter status", () => {
const wrapper = mount(<Logs logs={fakeLogs()} bot={bot} />);
wrapper.setState({
success: 3, busy: 3, warn: 3, error: 3, info: 3, fun: 3, debug: 3
});
const filterBtn = wrapper.find("button").first();
expect(filterBtn.text().toLowerCase()).toEqual("filter");
expect(filterBtn.hasClass("gray")).toBeTruthy();
});
it("toggles filter", () => {
mockStorj[NumericSetting.warnLog] = 3;
const wrapper = mount(<Logs logs={fakeLogs()} bot={bot} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
expect(wrapper.state().warn).toEqual(3);
instance.toggle("warn")();
expect(wrapper.state().warn).toEqual(0);
instance.toggle("warn")();
expect(wrapper.state().warn).toEqual(1);
});
it("sets filter", () => {
mockStorj[NumericSetting.warnLog] = 3;
const wrapper = mount(<Logs logs={fakeLogs()} bot={bot} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
expect(wrapper.state().warn).toEqual(3);
instance.setFilterLevel("warn")(2);
expect(wrapper.state().warn).toEqual(2);
});
});

View File

@ -4,23 +4,40 @@ import { LogsFilterMenu } from "../filter_menu";
describe("<LogsFilterMenu />", () => {
const fakeState = {
autoscroll: true, success: true, busy: true, warn: true,
error: true, info: true, fun: true, debug: true
autoscroll: true, success: 1, busy: 1, warn: 1,
error: 1, info: 1, fun: 1, debug: 1
};
it("renders", () => {
const wrapper = mount(
<LogsFilterMenu toggle={jest.fn()} state={fakeState} />);
<LogsFilterMenu
toggle={jest.fn()} setFilterLevel={jest.fn()} state={fakeState} />);
["success", "busy", "warn", "error", "info", "fun", "debug"]
.map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
expect(wrapper.text().toLowerCase())
.toContain(string.toLowerCase()));
expect(wrapper.text()).not.toContain("autscroll");
});
it("filters logs", () => {
const toggle = jest.fn();
const setFilterLevel = jest.fn();
const wrapper = mount(
<LogsFilterMenu toggle={(x) => () => toggle(x)} state={fakeState} />);
<LogsFilterMenu
toggle={(x) => () => toggle(x)}
setFilterLevel={(x) => () => setFilterLevel(x)}
state={fakeState} />);
wrapper.find("button").first().simulate("click");
expect(toggle).toHaveBeenCalledWith("success");
});
it("shows filter status", () => {
fakeState.debug = 3;
fakeState.success = 0;
const wrapper = mount(
<LogsFilterMenu
toggle={jest.fn()} setFilterLevel={jest.fn()} state={fakeState} />);
const toggles = wrapper.find("button");
expect(toggles.last().hasClass("green")).toBeTruthy();
expect(toggles.first().hasClass("red")).toBeTruthy();
});
});

View File

@ -12,8 +12,13 @@ import { bot } from "../../../__test_support__/fake_state/bot";
import { ConfigurationName } from "farmbot";
describe("<LogsSettingsMenu />", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders", () => {
const wrapper = mount(<LogsSettingsMenu {...bot} />);
const wrapper = mount(<LogsSettingsMenu
bot={bot} setFilterLevel={() => jest.fn()} />);
["begin", "steps", "complete"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
@ -21,7 +26,8 @@ describe("<LogsSettingsMenu />", () => {
function testSettingToggle(setting: ConfigurationName, position: number) {
it("toggles setting", () => {
bot.hardware.configuration[setting] = false;
const wrapper = mount(<LogsSettingsMenu {...bot} />);
const wrapper = mount(<LogsSettingsMenu
bot={bot} setFilterLevel={() => jest.fn()} />);
wrapper.find("button").at(position).simulate("click");
expect(mockDevice.updateConfig)
.toHaveBeenCalledWith({ [setting]: true });
@ -30,4 +36,6 @@ describe("<LogsSettingsMenu />", () => {
testSettingToggle("sequence_init_log", 0);
testSettingToggle("sequence_body_log", 1);
testSettingToggle("sequence_complete_log", 2);
testSettingToggle("firmware_output_log" as ConfigurationName, 3);
testSettingToggle("firmware_input_log" as ConfigurationName, 4);
});

View File

@ -1,9 +1,11 @@
import * as React from "react";
import { LogsFilterMenuProps, LogsState } from "../interfaces";
import * as _ from "lodash";
import { Slider } from "@blueprintjs/core";
export const LogsFilterMenu = (props: LogsFilterMenuProps) => {
const btnColor = (x: keyof LogsState) => props.state[x] ? "green" : "red";
const btnColor = (x: keyof LogsState) => props.state[x] != 0
? "green" : "red";
return <div className={"logs-settings-menu"}>
{Object.keys(props.state)
.filter(x => { if (!(x == "autoscroll")) { return x; } })
@ -16,6 +18,9 @@ export const LogsFilterMenu = (props: LogsFilterMenuProps) => {
<button
className={"fb-button fb-toggle-button " + btnColor(logType)}
onClick={props.toggle(logType)} />
<Slider min={0} max={3} stepSize={1}
onChange={props.setFilterLevel(logType)}
value={props.state[logType] as number} />
</fieldset>;
})}
</div>;

View File

@ -7,17 +7,19 @@ import * as _ from "lodash";
const LogsRow = (tlog: TaggedLog, state: LogsState) => {
const log = tlog.body;
const { x, y, z, verbosity } = log.meta;
const time = formatLogTime(log.created_at);
const type = (log.meta || {}).type;
const filtered = state[type as keyof LogsState];
const displayLog = _.isUndefined(filtered) || filtered;
const { x, y, z } = log.meta;
const filterLevel = state[type as keyof LogsState];
const displayLog = verbosity
? verbosity <= filterLevel
: filterLevel != 0;
return displayLog ?
<tr key={tlog.uuid}>
<td>
<div className={`saucer ${type}`}>
<p>
{log.meta.verbosity}
{verbosity}
</p>
</div>
{_.startCase(type)}

View File

@ -1,16 +1,15 @@
import * as React from "react";
import { t } from "i18next";
import { BotState } from "../../devices/interfaces";
import { Help } from "../../ui/index";
import { ToolTips } from "../../constants";
import { ToggleButton } from "../../controls/toggle_button";
import { updateConfig } from "../../devices/actions";
import { noop } from "lodash";
import { LogSettingProps } from "../interfaces";
import { LogSettingProps, LogsSettingsMenuProps } from "../interfaces";
import { ConfigurationName } from "farmbot";
const LogSetting = (props: LogSettingProps) => {
const { label, setting, toolTip, value } = props;
const { label, setting, toolTip, value, setFilterLevel } = props;
return <fieldset>
<label>
{t(label)}
@ -19,11 +18,29 @@ const LogSetting = (props: LogSettingProps) => {
<ToggleButton toggleValue={value}
toggleAction={() => {
updateConfig({ [setting]: !value })(noop);
if (!value === true) {
switch (setting) {
case "firmware_output_log" as ConfigurationName:
case "firmware_input_log" as ConfigurationName:
setFilterLevel("debug")(3);
break;
case "sequence_init_log":
setFilterLevel("busy")(2);
break;
case "sequence_body_log":
setFilterLevel("info")(2);
break;
case "sequence_complete_log":
setFilterLevel("success")(2);
break;
}
}
}} />
</fieldset>;
};
export const LogsSettingsMenu = (bot: BotState) => {
export const LogsSettingsMenu = (props: LogsSettingsMenuProps) => {
const { bot, setFilterLevel } = props;
const { configuration } = bot.hardware;
return <div className={"logs-settings-menu"}>
{t("Create logs for sequence:")}
@ -31,17 +48,20 @@ export const LogsSettingsMenu = (bot: BotState) => {
label={"Begin"}
setting={"sequence_init_log"}
toolTip={ToolTips.SEQUENCE_LOG_BEGIN}
value={configuration.sequence_init_log} />
value={configuration.sequence_init_log}
setFilterLevel={setFilterLevel} />
<LogSetting
label={"Steps"}
setting={"sequence_body_log"}
toolTip={ToolTips.SEQUENCE_LOG_STEP}
value={configuration.sequence_body_log} />
value={configuration.sequence_body_log}
setFilterLevel={setFilterLevel} />
<LogSetting
label={"Complete"}
setting={"sequence_complete_log"}
toolTip={ToolTips.SEQUENCE_LOG_END}
value={configuration.sequence_complete_log} />
value={configuration.sequence_complete_log}
setFilterLevel={setFilterLevel} />
{t("Firmware Logs:")}
{/*
// TODO: remove type assertions when names are added to farmbot-js
@ -50,11 +70,13 @@ export const LogsSettingsMenu = (bot: BotState) => {
label={"Sent"}
setting={"firmware_output_log" as ConfigurationName}
toolTip={ToolTips.FIRMWARE_LOG_SENT}
value={!!configuration["firmware_output_log" as ConfigurationName]} />
value={!!configuration["firmware_output_log" as ConfigurationName]}
setFilterLevel={setFilterLevel} />
<LogSetting
label={"Received"}
setting={"firmware_input_log" as ConfigurationName}
toolTip={ToolTips.FIRMWARE_LOG_RECEIVED}
value={!!configuration["firmware_input_log" as ConfigurationName]} />
value={!!configuration["firmware_input_log" as ConfigurationName]}
setFilterLevel={setFilterLevel} />
</div>;
};

View File

@ -10,6 +10,9 @@ import { ToolTips } from "../constants";
import { LogsSettingsMenu } from "./components/settings_menu";
import { LogsFilterMenu } from "./components/filter_menu";
import { LogsTable } from "./components/logs_table";
import { Session, safeNumericSetting } from "../session";
import { isUndefined } from "lodash";
import { NumericSetting } from "../session_keys";
export const formatLogTime = (created_at: number) =>
moment.unix(created_at).local().format("MMM D, h:mma");
@ -17,43 +20,73 @@ export const formatLogTime = (created_at: number) =>
@connect(mapStateToProps)
export class Logs extends React.Component<LogsProps, Partial<LogsState>> {
initialize = (name: NumericSetting, defaultValue: number): number => {
const currentValue = Session.getNum(safeNumericSetting(name));
if (isUndefined(currentValue)) {
Session.setNum(safeNumericSetting(name), defaultValue);
return defaultValue;
} else {
return currentValue;
}
}
state: LogsState = {
autoscroll: false,
success: true,
busy: true,
warn: true,
error: true,
info: true,
fun: true,
debug: true,
success: this.initialize(NumericSetting.successLog, 1),
busy: this.initialize(NumericSetting.busyLog, 1),
warn: this.initialize(NumericSetting.warnLog, 1),
error: this.initialize(NumericSetting.errorLog, 1),
info: this.initialize(NumericSetting.infoLog, 1),
fun: this.initialize(NumericSetting.funLog, 1),
debug: this.initialize(NumericSetting.debugLog, 1),
};
toggle = (name: keyof LogsState) =>
() => this.setState({ [name]: !this.state[name] });
toggle = (name: keyof LogsState) => {
switch (this.state[name]) {
case 0:
return () => {
this.setState({ [name]: 1 });
Session.setNum(safeNumericSetting(name + "Log"), 1);
};
default:
return () => {
this.setState({ [name]: 0 });
Session.setNum(safeNumericSetting(name + "Log"), 0);
};
}
}
setFilterLevel = (name: keyof LogsState) => {
return (value: number) => {
this.setState({ [name]: value });
Session.setNum(safeNumericSetting(name + "Log"), value);
};
};
get filterActive() {
const filterKeys = Object.keys(this.state)
.filter(x => !(x === "autoscroll"));
const filterValues = filterKeys
.map((key: keyof LogsState) => this.state[key]);
return !filterValues.every(x => x);
return !filterValues.every(x => x == 3);
}
render() {
const filterBtnColor = this.filterActive ? "green" : "gray";
return <Page className="logs">
<Row>
<Col xs={11}>
<Col xs={10}>
<h3>
<i>{t("Logs")}</i>
</h3>
<ToolTip helpText={ToolTips.LOGS} />
</Col>
<Col xs={1}>
<Col xs={2}>
<div className={"settings-menu-button"}>
<Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<LogsSettingsMenu {...this.props.bot} />
<LogsSettingsMenu
setFilterLevel={this.setFilterLevel} bot={this.props.bot} />
</Popover>
</div>
<div className={"settings-menu-button"}>
@ -61,7 +94,9 @@ export class Logs extends React.Component<LogsProps, Partial<LogsState>> {
<button className={`fb-button ${filterBtnColor}`}>
{this.filterActive ? t("Filters active") : t("filter")}
</button>
<LogsFilterMenu toggle={this.toggle} state={this.state} />
<LogsFilterMenu
toggle={this.toggle} state={this.state}
setFilterLevel={this.setFilterLevel} />
</Popover>
</div>
</Col>

View File

@ -8,13 +8,13 @@ export interface LogsProps {
}
export interface Filters {
success: boolean;
busy: boolean;
warn: boolean;
error: boolean;
info: boolean;
fun: boolean;
debug: boolean;
success: number;
busy: number;
warn: number;
error: number;
info: number;
fun: number;
debug: number;
}
export interface LogsState extends Filters {
@ -27,15 +27,23 @@ export interface LogsTableProps {
}
type ToggleEventHandler = (e: React.MouseEvent<HTMLButtonElement>) => void;
type SetNumSetting = (property: keyof LogsState) => (value: number) => void;
export interface LogsFilterMenuProps {
toggle: (property: keyof LogsState) => ToggleEventHandler;
state: LogsState;
setFilterLevel: SetNumSetting;
}
export interface LogSettingProps {
label: string;
setting: ConfigurationName;
toolTip: string;
value: boolean | undefined;
value: boolean | number | undefined;
setFilterLevel: SetNumSetting;
}
export interface LogsSettingsMenuProps {
bot: BotState;
setFilterLevel: SetNumSetting;
}

View File

@ -1,8 +1,31 @@
const mockStorj: Dictionary<number | boolean> = {};
jest.mock("../../session", () => {
return {
Session: {
getNum: (k: string) => {
return mockStorj[k];
},
setNum: (k: string, v: number) => {
mockStorj[k] = v;
},
getBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
}
},
// tslint:disable-next-line:no-any
safeNumericSetting: (x: any) => x
};
});
import * as React from "react";
import { mount } from "enzyme";
import { TickerList } from "../ticker_list";
import { Log } from "../../interfaces";
import { Dictionary } from "farmbot";
describe("<TickerList />", () => {
const log: Log = {
@ -64,4 +87,16 @@ describe("<TickerList />", () => {
expect(labels.at(3).text()).toContain(":50pm");
expect(labels.at(4).text()).toEqual("Filter logs");
});
it("all logs filtered out", () => {
["success", "busy", "warn", "error", "info", "fun", "debug"]
.map(logType => mockStorj[logType + "Log"] = 0);
log.meta.verbosity = 1;
const wrapper = mount(<TickerList
logs={[log]} tickerListOpen={false} toggle={jest.fn()} />);
const labels = wrapper.find("label");
expect(labels.length).toEqual(2);
expect(labels.at(0).text())
.toContain("No logs to display. Visit Logs page to view filters.");
});
});

View File

@ -6,6 +6,44 @@ import { TickerListProps } from "./interfaces";
import { Link } from "react-router";
import { t } from "i18next";
import { formatLogTime } from "../logs/index";
import { Session, safeNumericSetting } from "../session";
import { isNumber } from "lodash";
const logFilter = (log: Log): Log | undefined => {
const type = (log.meta || {}).type;
const { verbosity } = log.meta;
const filterLevel = Session.getNum(safeNumericSetting(type + "Log"));
const filterLevelCompare = isNumber(filterLevel) ? filterLevel : 1;
const displayLog = verbosity
? verbosity <= filterLevelCompare
: filterLevel != 0;
const whitelisted = !log.message.toLowerCase().includes("filtered");
if (displayLog && whitelisted) {
return log;
}
return;
};
const getfirstTickerLog = (logs: Log[]): Log => {
if (logs.length == 0) {
return {
message: "No logs yet.",
meta: { type: "debug", verbosity: -1 },
channels: [], created_at: NaN
};
} else {
const filteredLogs = logs.filter(log => logFilter(log));
if (filteredLogs.length > 0) {
return filteredLogs[0];
} else {
return {
message: "No logs to display. Visit Logs page to view filters.",
meta: { type: "debug", verbosity: -1 },
channels: [], created_at: NaN
};
}
}
};
const Ticker = (log: Log, index: number) => {
const time = formatLogTime(log.created_at);
@ -27,26 +65,19 @@ const Ticker = (log: Log, index: number) => {
};
export let TickerList = (props: TickerListProps) => {
const firstTicker: Log = props.logs.filter(
log => !log.message.toLowerCase().includes("filtered"))[0];
const noLogs: Log = {
message: "No logs yet.", meta: { type: "debug" }, channels: [], created_at: NaN
};
return (
<div
className="ticker-list"
onClick={props.toggle("tickerListOpen")} >
<div className="first-ticker">
{Ticker(firstTicker || noLogs, -1)}
{Ticker(getfirstTickerLog(props.logs), -1)}
</div>
<Collapse isOpen={props.tickerListOpen}>
{props
.logs
.filter((log, index) => index !== 0)
.map((log: Log, index: number) => {
const isFiltered = log.message.toLowerCase().includes("filtered");
if (!isFiltered) { return Ticker(log, index); }
})}
.filter((log) => logFilter(log))
.map((log: Log, index: number) => Ticker(log, index))}
</Collapse>
<Collapse isOpen={props.tickerListOpen}>
<Link to={"/app/logs"}>

View File

@ -84,3 +84,15 @@ export function safeBooleanSettting(name: string): BooleanSetting {
throw new Error(`Expected BooleanSetting but got '${name}'`);
}
}
const isNumericSetting =
// tslint:disable-next-line:no-any
(x: any): x is NumericSetting => !!NumericSetting[x];
export function safeNumericSetting(name: string): NumericSetting {
if (isNumericSetting(name)) {
return name;
} else {
throw new Error(`Expected NumericSetting but got '${name}'`);
}
}

View File

@ -24,5 +24,12 @@ export enum BooleanSetting {
export enum NumericSetting {
botOriginQuadrant = "botOriginQuadrant",
zoomLevel = "zoomLevel"
zoomLevel = "zoomLevel",
successLog = "successLog",
busyLog = "busyLog",
warnLog = "warnLog",
errorLog = "errorLog",
infoLog = "infoLog",
funLog = "funLog",
debugLog = "debugLog"
}