Compare commits

..

No commits in common. 'master' and 'develop' have entirely different histories.

  1. 125
      client/components/DurationView.tsx
  2. 6
      client/components/ProgramSequenceView.tsx
  3. 7
      client/components/ProgramTable.tsx
  4. 44
      client/pages/ProgramPage.tsx
  5. 4
      client/sprinklersRpc/WebSocketRpcClient.ts
  6. 8
      client/state/AppState.ts
  7. 26
      client/state/reactContext.tsx
  8. 15
      client/styles/DeviceView.scss
  9. 5
      client/webpack.config.js
  10. 7
      common/sprinklersRpc/Program.ts
  11. 2
      common/sprinklersRpc/mqtt/MqttProgram.ts
  12. 5
      common/sprinklersRpc/schema/index.ts
  13. 7
      package.json
  14. 2
      server/authentication.ts
  15. 32
      server/commands/token.ts
  16. 12
      yarn.lock

125
client/components/DurationView.tsx

@ -6,90 +6,13 @@ import { Duration } from "@common/Duration";
import "@client/styles/DurationView"; import "@client/styles/DurationView";
export interface DurationViewProps { export default class DurationView extends React.Component<{
label?: string; label?: string;
inline?: boolean; inline?: boolean;
duration: Duration; duration: Duration;
onDurationChange?: (newDuration: Duration) => void; onDurationChange?: (newDuration: Duration) => void;
className?: string; className?: string;
} }> {
function roundOrString(val: number | string): number | string {
if (typeof val === "number") {
return Math.round(val);
} else {
return val;
}
}
interface NumberInputProps {
className?: string;
label?: string;
value: number;
max?: number;
onChange: (value: number) => void;
}
function NumberInput(props: NumberInputProps): React.ReactElement {
const [valueState, setValueState] = React.useState<number | string>(props.value);
const [elementId, setElementId] = React.useState(() => `NumberInput-${Math.round(Math.random() * 100000000)}`);
const [isWheelChange, setIsWheelChange] = React.useState(false);
const onChange: InputProps["onChange"] = (_e, data) => {
setValueState(data.value);
const newValue = parseFloat(data.value);
if (!isNaN(newValue) && data.value.length > 0 && isWheelChange) {
props.onChange(Math.round(newValue));
setIsWheelChange(false);
}
};
const onBlur: React.FocusEventHandler = () => {
const newValue = (typeof valueState === "number") ? valueState : parseFloat(valueState);
if (!props.onChange || isNaN(newValue)) {
return;
}
if (props.value !== newValue) {
props.onChange(Math.round(newValue));
}
};
const onWheel = (e: Event) => {
// do nothing
setIsWheelChange(true);
};
React.useEffect(() => {
const el = document.getElementById(elementId);
if (el) {
// Not passive events
el.addEventListener("wheel", onWheel);
}
});
React.useEffect(() => {
if (props.value !== valueState) {
setValueState(props.value);
}
}, [props.value]);
return <Input
id={elementId}
type="number"
pattern="[0-9\.]*" // for safari
inputMode="numeric"
value={roundOrString(valueState)}
onChange={onChange}
// onMouseOut={onBlur}
onBlur={onBlur}
className={props.className}
label={props.label}
max={props.max}
labelPosition="right"
/>
}
export default class DurationView extends React.Component<DurationViewProps> {
render() { render() {
const { duration, label, inline, onDurationChange, className } = this.props; const { duration, label, inline, onDurationChange, className } = this.props;
const inputsClassName = classNames("durationInputs", { inline }); const inputsClassName = classNames("durationInputs", { inline });
@ -99,18 +22,24 @@ export default class DurationView extends React.Component<DurationViewProps> {
<Form.Field inline={inline} className={className}> <Form.Field inline={inline} className={className}>
{label && <label>{label}</label>} {label && <label>{label}</label>}
<div className={inputsClassName}> <div className={inputsClassName}>
<NumberInput <Input
type="number"
className="durationInput minutes" className="durationInput minutes"
value={this.props.duration.minutes} value={duration.minutes}
onChange={this.onMinutesChange} onChange={this.onMinutesChange}
label="M" label="M"
labelPosition="right"
onWheel={this.onWheel}
/> />
<NumberInput <Input
type="number"
className="durationInput seconds" className="durationInput seconds"
value={this.props.duration.seconds} value={duration.seconds}
onChange={this.onSecondsChange} onChange={this.onSecondsChange}
max={60} max="60"
label="S" label="S"
labelPosition="right"
onWheel={this.onWheel}
/> />
</div> </div>
</Form.Field> </Form.Field>
@ -126,25 +55,23 @@ export default class DurationView extends React.Component<DurationViewProps> {
} }
} }
componentWillReceiveProps(nextProps: Readonly<DurationViewProps>) { private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
if (nextProps.duration.minutes !== this.props.duration.minutes || if (!this.props.onDurationChange || isNaN(Number(value))) {
nextProps.duration.seconds !== this.props.duration.seconds) { return;
this.setState({
minutes: nextProps.duration.minutes,
seconds: nextProps.duration.seconds,
});
} }
} const newMinutes = Number(value);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
};
private onMinutesChange = (newMinutes: number) => { private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
if (this.props.onDurationChange) { if (!this.props.onDurationChange || isNaN(Number(value))) {
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes)); return;
} }
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}; };
private onSecondsChange = (newSeconds: number) => { private onWheel = () => {
if (this.props.onDurationChange) { // do nothing
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}
}; };
} }

6
client/components/ProgramSequenceView.tsx

@ -105,17 +105,16 @@ class ProgramSequenceItem extends React.Component<{
const ProgramSequenceItemD = SortableElement(ProgramSequenceItem); const ProgramSequenceItemD = SortableElement(ProgramSequenceItem);
// tslint:disable: no-shadowed-variable
const ProgramSequenceList = SortableContainer( const ProgramSequenceList = SortableContainer(
observer( observer(
function ProgramSequenceList(props: { (props: {
className: string; className: string;
list: ProgramItem[]; list: ProgramItem[];
sections: Section[]; sections: Section[];
editing: boolean; editing: boolean;
onChange: ItemChangeHandler; onChange: ItemChangeHandler;
onRemove: ItemRemoveHandler; onRemove: ItemRemoveHandler;
}) { }) => {
const { className, list, sections, ...rest } = props; const { className, list, sections, ...rest } = props;
const listItems = list.map((item, index) => { const listItems = list.map((item, index) => {
const key = `item-${index}`; const key = `item-${index}`;
@ -133,6 +132,7 @@ const ProgramSequenceList = SortableContainer(
return <ul className={className}>{listItems}</ul>; return <ul className={className}>{listItems}</ul>;
} }
), ),
{ withRef: true }
); );
@observer @observer

7
client/components/ProgramTable.tsx

@ -8,7 +8,6 @@ import { ProgramSequenceView, ScheduleView } from "@client/components";
import * as route from "@client/routePaths"; import * as route from "@client/routePaths";
import { ISprinklersDevice } from "@common/httpApi"; import { ISprinklersDevice } from "@common/httpApi";
import { Program, SprinklersDevice } from "@common/sprinklersRpc"; import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import moment = require("moment");
@observer @observer
class ProgramRows extends React.Component<{ class ProgramRows extends React.Component<{
@ -70,12 +69,6 @@ class ProgramRows extends React.Component<{
<h4>Sequence: </h4>{" "} <h4>Sequence: </h4>{" "}
<ProgramSequenceView sequence={sequence} sections={sections} /> <ProgramSequenceView sequence={sequence} sections={sections} />
<ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} /> <ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} />
<h4 className="program--nextRun">Next run: </h4>
{
program.nextRun
? <time title={moment(program.nextRun).toString()}>{moment(program.nextRun).fromNow()}</time>
: <time title="never">never</time>
}
</Form> </Form>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>

44
client/pages/ProgramPage.tsx

@ -20,9 +20,7 @@ import { AppState, injectState } from "@client/state";
import { ISprinklersDevice } from "@common/httpApi"; import { ISprinklersDevice } from "@common/httpApi";
import log from "@common/logger"; import log from "@common/logger";
import { Program, SprinklersDevice } from "@common/sprinklersRpc"; import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import classNames = require("classnames");
import { action } from "mobx"; import { action } from "mobx";
import * as moment from "moment";
interface ProgramPageProps interface ProgramPageProps
extends RouteComponentProps<{ deviceId: string; programId: string }> { extends RouteComponentProps<{ deviceId: string; programId: string }> {
@ -175,10 +173,8 @@ class ProgramPage extends React.Component<ProgramPageProps> {
const { running, enabled, schedule, sequence } = program; const { running, enabled, schedule, sequence } = program;
const className = classNames("programEditor", editing && "editing");
return ( return (
<Modal open onClose={this.close} className={className}> <Modal open onClose={this.close} className="programEditor">
<Modal.Header>{this.renderName(program)}</Modal.Header> <Modal.Header>{this.renderName(program)}</Modal.Header>
<Modal.Content> <Modal.Content>
<Form> <Form>
@ -187,6 +183,7 @@ class ProgramPage extends React.Component<ProgramPageProps> {
toggle toggle
label="Enabled" label="Enabled"
checked={enabled} checked={enabled}
readOnly={!editing}
onChange={this.onEnabledChange} onChange={this.onEnabledChange}
/> />
<Form.Checkbox <Form.Checkbox
@ -211,16 +208,6 @@ class ProgramPage extends React.Component<ProgramPageProps> {
editing={editing} editing={editing}
label={<h4>Schedule</h4>} label={<h4>Schedule</h4>}
/> />
{ !editing && (
<h4 className="program--nextRun">Next run: </h4>)
}
{
!editing && (
program.nextRun
? <time title={moment(program.nextRun).toString()}>{moment(program.nextRun).fromNow()}</time>
: <time title="never">never</time>
)
}
</Form> </Form>
</Modal.Content> </Modal.Content>
{this.renderActions(program)} {this.renderActions(program)}
@ -253,10 +240,6 @@ class ProgramPage extends React.Component<ProgramPageProps> {
}, },
err => { err => {
log.error({ err }, "error updating Program"); log.error({ err }, "error updating Program");
this.props.appState.uiStore.addMessage({
error: true,
content: `Error updating program: ${err}`,
});
} }
); );
this.stopEditing(); this.stopEditing();
@ -282,28 +265,11 @@ class ProgramPage extends React.Component<ProgramPageProps> {
@action.bound @action.bound
private onEnabledChange(e: any, p: CheckboxProps) { private onEnabledChange(e: any, p: CheckboxProps) {
if (p.checked !== undefined && this.program) { if (this.programView) {
this.program.enabled = p.checked; this.programView.enabled = p.checked!;
this.program.update().then(
data => {
log.info({ data }, "Program updated");
this.props.appState.uiStore.addMessage({
success: true,
content: `Program ${this.program!.name} ${this.program!.enabled ? "enabled" : "disabled"}`,
timeout: 2000,
});
},
err => {
log.error({ err }, "error updating Program");
this.props.appState.uiStore.addMessage({
error: true,
content: `Error updating program: ${err}`,
});
}
);
} }
} }
} }
const DecoratedProgramPage = injectState(withRouter(ProgramPage)); const DecoratedProgramPage = injectState(withRouter(observer(ProgramPage)));
export default DecoratedProgramPage; export default DecoratedProgramPage;

4
client/sprinklersRpc/WebSocketRpcClient.ts

@ -92,10 +92,6 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
this._connect(); this._connect();
} }
reconnect() {
this._connect();
}
stop() { stop() {
if (this.reconnectTimer != null) { if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);

8
client/state/AppState.ts

@ -38,16 +38,8 @@ export default class AppState extends TypedEventEmitter<AppEvents> {
when(() => !this.tokenStore.accessToken.isValid, this.checkToken); when(() => !this.tokenStore.accessToken.isValid, this.checkToken);
this.sprinklersRpc.start(); this.sprinklersRpc.start();
}); });
document.addEventListener("visibilitychange", this.onPageFocus);
} }
onPageFocus = () => {
if (document.visibilityState === "visible") {
this.sprinklersRpc.reconnect();
}
};
@computed @computed
get isLoggedIn() { get isLoggedIn() {
return this.tokenStore.accessToken.isValid; return this.tokenStore.accessToken.isValid;

26
client/state/reactContext.tsx

@ -33,16 +33,20 @@ export function ConsumeState({ children }: ConsumeStateProps) {
export function injectState<P extends { appState: AppState }>( export function injectState<P extends { appState: AppState }>(
Component: React.ComponentType<P> Component: React.ComponentType<P>
): React.FunctionComponent<Omit<P, "appState">> { ): React.ComponentClass<Omit<P, "appState">> {
return function InjectState(props) { return class extends React.Component<Omit<P, "appState">> {
const state = React.useContext(StateContext); render() {
if (state == null) { const consumeState = (state: AppState | null) => {
throw new Error( if (state == null) {
"Component with injectState must be mounted inside ProvideState" throw new Error(
); "Component with injectState must be mounted inside ProvideState"
);
}
// tslint:disable-next-line:no-object-literal-type-assertion
const allProps: Readonly<P> = {...this.props, appState: state} as Readonly<P>;
return <Component {...allProps} />;
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
} }
// tslint:disable-next-line: no-object-literal-type-assertion };
const allProps: Readonly<P> = {...props, appState: state} as Readonly<P>;
return <Component {...allProps} />;
}
} }

15
client/styles/DeviceView.scss

@ -78,19 +78,8 @@ $connected-color: #13d213;
color: green; color: green;
} }
.program--nextRun { .ui.modal.programEditor > .header > .header.item .inline.fields {
display: inline-block; margin-bottom: 0;
padding-right: 0.5em;
}
.ui.modal.programEditor {
&.editing > .content {
min-height: 80vh;
}
> .header > .header.item .inline.fields {
margin-bottom: 0;
}
} }
.runSectionForm-runButton { .runSectionForm-runButton {

5
client/webpack.config.js

@ -235,8 +235,7 @@ function getConfig(env) {
extensions: [".ts", ".tsx", ".js", ".json", ".scss"], extensions: [".ts", ".tsx", ".js", ".json", ".scss"],
alias: { alias: {
"@client": paths.clientDir, "@client": paths.clientDir,
"@common": paths.commonDir, "@common": paths.commonDir
"react-dom": isDev ? "@hot-loader/react-dom" : "react-dom"
} }
}, },
module: { rules }, module: { rules },
@ -258,7 +257,7 @@ function getConfig(env) {
target: paths.publicUrl target: paths.publicUrl
} }
] ]
}, }
}; };
} }

7
common/sprinklersRpc/Program.ts

@ -32,8 +32,6 @@ export class Program {
sequence: ProgramItem[] = []; sequence: ProgramItem[] = [];
@observable @observable
running: boolean = false; running: boolean = false;
@observable
nextRun: Date | null = null;
constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) { constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) {
this.device = device; this.device = device;
@ -62,8 +60,7 @@ export class Program {
enabled: this.enabled, enabled: this.enabled,
running: this.running, running: this.running,
schedule: this.schedule.clone(), schedule: this.schedule.clone(),
sequence: this.sequence.slice(), sequence: this.sequence.slice()
nextRun: this.nextRun,
}); });
} }
@ -71,7 +68,7 @@ export class Program {
return ( return (
`Program{name="${this.name}", enabled=${this.enabled}, schedule=${ `Program{name="${this.name}", enabled=${this.enabled}, schedule=${
this.schedule this.schedule
}, ` + `sequence=${this.sequence}, running=${this.running}, nextRun=${this.nextRun}}` }, ` + `sequence=${this.sequence}, running=${this.running}}`
); );
} }
} }

2
common/sprinklersRpc/mqtt/MqttProgram.ts

@ -7,8 +7,6 @@ export class MqttProgram extends s.Program {
onMessage(payload: string, topic: string | undefined) { onMessage(payload: string, topic: string | undefined) {
if (topic === "running") { if (topic === "running") {
this.running = payload === "true"; this.running = payload === "true";
} else if (topic === "nextRun") {
this.nextRun = (payload.length > 0) ? new Date(Number(payload) * 1000.0) : null;
} else if (topic == null) { } else if (topic == null) {
this.updateFromJSON(JSON.parse(payload)); this.updateFromJSON(JSON.parse(payload));
} }

5
common/sprinklersRpc/schema/index.ts

@ -1,4 +1,4 @@
import { createSimpleSchema, ModelSchema, object, primitive, date } from "serializr"; import { createSimpleSchema, ModelSchema, object, primitive } from "serializr";
import * as s from ".."; import * as s from "..";
import list from "./list"; import list from "./list";
@ -85,8 +85,7 @@ export const program: ModelSchema<s.Program> = {
enabled: primitive(), enabled: primitive(),
schedule: object(schedule), schedule: object(schedule),
sequence: list(object(programItem)), sequence: list(object(programItem)),
running: primitive(), running: primitive()
nextRun: date(),
} }
}; };

7
package.json

@ -46,6 +46,8 @@
"@oclif/command": "^1.5.0", "@oclif/command": "^1.5.0",
"@oclif/config": "^1.7.4", "@oclif/config": "^1.7.4",
"@oclif/plugin-help": "^2.1.1", "@oclif/plugin-help": "^2.1.1",
"@types/pino-http": "^4.0.2",
"@types/split2": "^2.1.6",
"bcrypt": "^3.0.0", "bcrypt": "^3.0.0",
"body-parser": "^1.18.3", "body-parser": "^1.18.3",
"chalk": "^2.4.1", "chalk": "^2.4.1",
@ -73,7 +75,6 @@
"ws": "^7.1.1" "ws": "^7.1.1"
}, },
"devDependencies": { "devDependencies": {
"@hot-loader/react-dom": "^16.8.6",
"@types/async": "^3.0.0", "@types/async": "^3.0.0",
"@types/bcrypt": "^3.0.0", "@types/bcrypt": "^3.0.0",
"@types/classnames": "^2.2.6", "@types/classnames": "^2.2.6",
@ -85,7 +86,6 @@
"@types/node": "^11.11.3", "@types/node": "^11.11.3",
"@types/object-assign": "^4.0.30", "@types/object-assign": "^4.0.30",
"@types/pino": "^5.20.0", "@types/pino": "^5.20.0",
"@types/pino-http": "^4.0.2",
"@types/prop-types": "^15.5.5", "@types/prop-types": "^15.5.5",
"@types/pump": "^1.0.1", "@types/pump": "^1.0.1",
"@types/query-string": "^6.1.0", "@types/query-string": "^6.1.0",
@ -94,7 +94,6 @@
"@types/react-hot-loader": "^4.1.0", "@types/react-hot-loader": "^4.1.0",
"@types/react-router-dom": "^4.3.0", "@types/react-router-dom": "^4.3.0",
"@types/react-sortable-hoc": "^0.6.4", "@types/react-sortable-hoc": "^0.6.4",
"@types/split2": "^2.1.6",
"@types/through2": "^2.0.33", "@types/through2": "^2.0.33",
"@types/webpack-env": "^1.13.6", "@types/webpack-env": "^1.13.6",
"@types/ws": "^6.0.0", "@types/ws": "^6.0.0",
@ -124,7 +123,7 @@
"promise": "^8.0.1", "promise": "^8.0.1",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"query-string": "^6.1.0", "query-string": "^6.1.0",
"react": "^16.8.0", "react": "^16.6.3",
"react-dev-utils": "^9.0.1", "react-dev-utils": "^9.0.1",
"react-dom": "^16.6.3", "react-dom": "^16.6.3",
"react-hot-loader": "^4.3.5", "react-hot-loader": "^4.3.5",

2
server/authentication.ts

@ -22,7 +22,7 @@ if (!JWT_SECRET) {
const ISSUER = "sprinklers3"; const ISSUER = "sprinklers3";
const ACCESS_TOKEN_LIFETIME = 30 * 60; // 30 minutes const ACCESS_TOKEN_LIFETIME = 30 * 60; // 30 minutes
const REFRESH_TOKEN_LIFETIME = 7 * 24 * 60 * 60; // 7 days const REFRESH_TOKEN_LIFETIME = 24 * 60 * 60; // 24 hours
function signToken( function signToken(
claims: tok.TokenClaimTypes, claims: tok.TokenClaimTypes,

32
server/commands/token.ts

@ -1,32 +0,0 @@
import { flags } from "@oclif/command";
import * as auth from "@server/authentication"
import ManageCommand from "@server/ManageCommand";
// tslint:disable:no-shadowed-variable
export default class TokenCommand extends ManageCommand {
static description = "Manage tokens";
static flags = {
"gen-device-reg": flags.boolean({
char: "d",
description: "Generate a device registration token",
}),
};
async run() {
const parseResult = this.parse(TokenCommand);
const flags = parseResult.flags;
if (flags["gen-device-reg"]) {
const token = await auth.generateDeviceRegistrationToken();
this.log(`Device registration token: "${token}"`)
} else {
this.error("Must specify a command to run");
this._help();
}
}
}

12
yarn.lock

@ -56,16 +56,6 @@
reflect-metadata "^0.1.12" reflect-metadata "^0.1.12"
tslib "^1.8.1" tslib "^1.8.1"
"@hot-loader/react-dom@^16.8.6":
version "16.8.6"
resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.8.6.tgz#7923ba27db1563a7cc48d4e0b2879a140df461ea"
integrity sha512-+JHIYh33FVglJYZAUtRjfT5qZoT2mueJGNzU5weS2CVw26BgbxGKSujlJhO85BaRbg8sqNWyW1hYBILgK3ZCgA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.6"
"@most/multicast@^1.2.5": "@most/multicast@^1.2.5":
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/@most/multicast/-/multicast-1.3.0.tgz#e01574840df634478ac3fabd164c6e830fb3b966" resolved "https://registry.yarnpkg.com/@most/multicast/-/multicast-1.3.0.tgz#e01574840df634478ac3fabd164c6e830fb3b966"
@ -7191,7 +7181,7 @@ react-sortable-hoc@^1.9.1:
invariant "^2.2.4" invariant "^2.2.4"
prop-types "^15.5.7" prop-types "^15.5.7"
react@^16.8.0: react@^16.6.3:
version "16.8.6" version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==

Loading…
Cancel
Save