Compare commits

...

9 Commits

  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,13 +6,90 @@ import { Duration } from "@common/Duration"; @@ -6,13 +6,90 @@ import { Duration } from "@common/Duration";
import "@client/styles/DurationView";
export default class DurationView extends React.Component<{
export interface DurationViewProps {
label?: string;
inline?: boolean;
duration: Duration;
onDurationChange?: (newDuration: Duration) => void;
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() {
const { duration, label, inline, onDurationChange, className } = this.props;
const inputsClassName = classNames("durationInputs", { inline });
@ -22,24 +99,18 @@ export default class DurationView extends React.Component<{ @@ -22,24 +99,18 @@ export default class DurationView extends React.Component<{
<Form.Field inline={inline} className={className}>
{label && <label>{label}</label>}
<div className={inputsClassName}>
<Input
type="number"
<NumberInput
className="durationInput minutes"
value={duration.minutes}
value={this.props.duration.minutes}
onChange={this.onMinutesChange}
label="M"
labelPosition="right"
onWheel={this.onWheel}
/>
<Input
type="number"
<NumberInput
className="durationInput seconds"
value={duration.seconds}
value={this.props.duration.seconds}
onChange={this.onSecondsChange}
max="60"
max={60}
label="S"
labelPosition="right"
onWheel={this.onWheel}
/>
</div>
</Form.Field>
@ -55,23 +126,25 @@ export default class DurationView extends React.Component<{ @@ -55,23 +126,25 @@ export default class DurationView extends React.Component<{
}
}
private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) {
return;
componentWillReceiveProps(nextProps: Readonly<DurationViewProps>) {
if (nextProps.duration.minutes !== this.props.duration.minutes ||
nextProps.duration.seconds !== this.props.duration.seconds) {
this.setState({
minutes: nextProps.duration.minutes,
seconds: nextProps.duration.seconds,
});
}
const newMinutes = Number(value);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
};
}
private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) {
return;
private onMinutesChange = (newMinutes: number) => {
if (this.props.onDurationChange) {
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
}
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
};
private onWheel = () => {
// do nothing
private onSecondsChange = (newSeconds: number) => {
if (this.props.onDurationChange) {
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}
};
}

6
client/components/ProgramSequenceView.tsx

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

7
client/components/ProgramTable.tsx

@ -8,6 +8,7 @@ import { ProgramSequenceView, ScheduleView } from "@client/components"; @@ -8,6 +8,7 @@ import { ProgramSequenceView, ScheduleView } from "@client/components";
import * as route from "@client/routePaths";
import { ISprinklersDevice } from "@common/httpApi";
import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import moment = require("moment");
@observer
class ProgramRows extends React.Component<{
@ -69,6 +70,12 @@ class ProgramRows extends React.Component<{ @@ -69,6 +70,12 @@ class ProgramRows extends React.Component<{
<h4>Sequence: </h4>{" "}
<ProgramSequenceView sequence={sequence} sections={sections} />
<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>
</Table.Cell>
</Table.Row>

44
client/pages/ProgramPage.tsx

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

4
client/sprinklersRpc/WebSocketRpcClient.ts

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

8
client/state/AppState.ts

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

26
client/state/reactContext.tsx

@ -33,20 +33,16 @@ export function ConsumeState({ children }: ConsumeStateProps) { @@ -33,20 +33,16 @@ export function ConsumeState({ children }: ConsumeStateProps) {
export function injectState<P extends { appState: AppState }>(
Component: React.ComponentType<P>
): React.ComponentClass<Omit<P, "appState">> {
return class extends React.Component<Omit<P, "appState">> {
render() {
const consumeState = (state: AppState | null) => {
if (state == null) {
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>;
): React.FunctionComponent<Omit<P, "appState">> {
return function InjectState(props) {
const state = React.useContext(StateContext);
if (state == null) {
throw new Error(
"Component with injectState must be mounted inside ProvideState"
);
}
};
// 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,8 +78,19 @@ $connected-color: #13d213; @@ -78,8 +78,19 @@ $connected-color: #13d213;
color: green;
}
.ui.modal.programEditor > .header > .header.item .inline.fields {
margin-bottom: 0;
.program--nextRun {
display: inline-block;
padding-right: 0.5em;
}
.ui.modal.programEditor {
&.editing > .content {
min-height: 80vh;
}
> .header > .header.item .inline.fields {
margin-bottom: 0;
}
}
.runSectionForm-runButton {

5
client/webpack.config.js

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

7
common/sprinklersRpc/Program.ts

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

2
common/sprinklersRpc/mqtt/MqttProgram.ts

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

5
common/sprinklersRpc/schema/index.ts

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

7
package.json

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

2
server/authentication.ts

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

32
server/commands/token.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
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,6 +56,16 @@ @@ -56,6 +56,16 @@
reflect-metadata "^0.1.12"
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":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@most/multicast/-/multicast-1.3.0.tgz#e01574840df634478ac3fabd164c6e830fb3b966"
@ -7181,7 +7191,7 @@ react-sortable-hoc@^1.9.1: @@ -7181,7 +7191,7 @@ react-sortable-hoc@^1.9.1:
invariant "^2.2.4"
prop-types "^15.5.7"
react@^16.6.3:
react@^16.8.0:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==

Loading…
Cancel
Save