Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Mikhalev c12f242cfa Updated deps (currently broken) 6 years ago
  1. 3
     
  2. 9
      Dockerfile
  3. 3
      Dockerfile.dev
  4. 6
      client/App.tsx
  5. 12
      client/components/DeviceView.tsx
  6. 125
      client/components/DurationView.tsx
  7. 2
      client/components/MessagesView.tsx
  8. 6
      client/components/ProgramSequenceView.tsx
  9. 7
      client/components/ProgramTable.tsx
  10. 22
      client/components/RunSectionForm.tsx
  11. 10
      client/pages/DevicesPage.tsx
  12. 46
      client/pages/ProgramPage.tsx
  13. 51
      client/sprinklersRpc/WebSocketRpcClient.ts
  14. 10
      client/state/AppState.ts
  15. 18
      client/state/UserStore.ts
  16. 30
      client/state/reactContext.tsx
  17. 59
      client/styles/DeviceView.scss
  18. 2
      client/styles/DurationView.scss
  19. 6
      client/styles/ProgramSequenceView.scss
  20. 7
      client/tsconfig.json
  21. 31
      client/webpack.config.js
  22. 4
      common/ErrorCode.ts
  23. 2
      common/jsonRpc/index.ts
  24. 7
      common/sprinklersRpc/Program.ts
  25. 2
      common/sprinklersRpc/mqtt/MqttProgram.ts
  26. 2
      common/sprinklersRpc/mqtt/index.ts
  27. 35
      common/sprinklersRpc/schema/common.ts
  28. 5
      common/sprinklersRpc/schema/index.ts
  29. 38
      common/sprinklersRpc/schema/list.ts
  30. 4
      common/sprinklersRpc/schema/requests.ts
  31. 5
      common/tsconfig.json
  32. 8
      docker-compose.dev.yml
  33. 2
      docker-compose.yml
  34. 146
      package.json
  35. 23
      server/Database.ts
  36. 21
      server/ManageCommand.ts
  37. 14
      server/UniqueConstraintError.ts
  38. 2
      server/authentication.ts
  39. 161
      server/commands/device.ts
  40. 11
      server/commands/manage.ts
  41. 32
      server/commands/token.ts
  42. 154
      server/commands/user.ts
  43. 2
      server/entities/SprinklersDevice.ts
  44. 6
      server/entities/User.ts
  45. 45
      server/express/api/devices.ts
  46. 11
      server/express/requestLogger.ts
  47. 1
      server/index.ts
  48. 16
      server/repositories/SprinklersDeviceRepository.ts
  49. 36
      server/repositories/UserRepository.ts
  50. 13
      server/state.ts
  51. 41
      server/tsconfig.json
  52. 7
      server/types/express-pino-logger.d.ts
  53. 18
      tsconfig.json
  54. 4676
      yarn.lock

3

@ -0,0 +1,3 @@
cmdline: /usr/lib/wireshark/extcap/sshdump --capture --extcap-interface sshdump --fifo /tmp/wireshark_extcap_sshdump_20180903010220_5gyEKt --remote-host RouterMain --remote-port 22 --remote-username root --sshkey /home/alex/.ssh/id_rsa --remote-interface eth0 --remote-capture-command --remote-sudo false --remote-filter not ((host fe80::9880:27ff:fec4:20a8 or host fe80::42:cff:fecd:a672 or host fe80::42:ff:fecd:47fc or host fe80::42:f7ff:fee1:ae70 or host fe80::ea43:5b74:219d:c5b7 or host 2001:470:b:a14:5751:93a2:2f5f:b9a0 or host fd7d:e461:6dfd:0:d0a6:7939:471:f8ff or host fd7d:e461:6dfd::c22 or host 2001:470:b:a14::c22 or host 172.19.0.1 or host 172.18.0.1 or host 172.17.0.1 or host 192.168.8.10) and port 22) --remote-count 0 --debug false --debug-file
Remote capture command has disabled other options
Running:

9
Dockerfile

@ -5,8 +5,8 @@ WORKDIR /app/
COPY package.json yarn.lock /app/ COPY package.json yarn.lock /app/
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY tsconfig.json tslint.json paths.js /app/ COPY tslint.json /app
COPY bin/ /app/bin COPY paths.js /app
COPY client/ /app/client COPY client/ /app/client
COPY common/ /app/common COPY common/ /app/common
COPY server/ /app/server COPY server/ /app/server
@ -19,11 +19,10 @@ FROM node:10
WORKDIR /app/ WORKDIR /app/
COPY --from=builder /app/package.json /app/yarn.lock /app/paths.js ./ COPY --from=builder /app/package.json /app/yarn.lock ./
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/bin ./bin
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT [ "node", ".", "serve"] ENTRYPOINT [ "node", "." ]

3
Dockerfile.dev

@ -5,7 +5,8 @@ WORKDIR /app/
COPY package.json yarn.lock /app/ COPY package.json yarn.lock /app/
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY tsconfig.json tslint.json paths.js /app/ COPY paths.js /app
COPY tslint.json /app
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT [ "npm", "run", "start:dev" ] ENTRYPOINT [ "npm", "run", "start:dev" ]

6
client/App.tsx

@ -1,6 +1,6 @@
// import DevTools from "mobx-react-devtools"; // import DevTools from "mobx-react-devtools";
import * as React from "react"; import * as React from "react";
import { Redirect, Route, Switch, withRouter } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { Container } from "semantic-ui-react"; import { Container } from "semantic-ui-react";
import { MessagesView, NavBar } from "@client/components"; import { MessagesView, NavBar } from "@client/components";
@ -30,7 +30,7 @@ function NavContainer() {
); );
} }
function App() { export default function App() {
return ( return (
<Switch> <Switch>
<Route path={route.login} component={p.LoginPage} /> <Route path={route.login} component={p.LoginPage} />
@ -39,5 +39,3 @@ function App() {
</Switch> </Switch>
); );
} }
export default withRouter(App);

12
client/components/DeviceView.tsx

@ -2,7 +2,7 @@ import * as classNames from "classnames";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Dimmer, Grid, Header, Icon, Item, SemanticICONS } from "semantic-ui-react"; import { Grid, Header, Icon, Item, SemanticICONS } from "semantic-ui-react";
import { DeviceImage } from "@client/components"; import { DeviceImage } from "@client/components";
import * as p from "@client/pages"; import * as p from "@client/pages";
@ -62,7 +62,7 @@ const ConnectionState = observer(
} }
); );
interface DeviceViewProps extends RouteComponentProps { interface DeviceViewProps {
deviceId: number; deviceId: number;
appState: AppState; appState: AppState;
inList?: boolean; inList?: boolean;
@ -87,13 +87,11 @@ class DeviceView extends React.Component<DeviceViewProps> {
return null; return null;
} }
const { connectionState, sectionRunner, sections } = this.device; const { connectionState, sectionRunner, sections } = this.device;
const dimmed = !connectionState.isDeviceConnected;
if (!connectionState.isAvailable || inList) { if (!connectionState.isAvailable || inList) {
return null; return null;
} }
return ( return (
<Dimmer.Dimmable blurring dimmed={dimmed} className="device-body"> <React.Fragment>
<Dimmer active={dimmed} />
<Grid> <Grid>
<Grid.Column mobile="16" tablet="16" computer="16" largeScreen="6"> <Grid.Column mobile="16" tablet="16" computer="16" largeScreen="6">
<SectionRunnerView <SectionRunnerView
@ -117,7 +115,7 @@ class DeviceView extends React.Component<DeviceViewProps> {
path={route.program(":deviceId", ":programId")} path={route.program(":deviceId", ":programId")}
component={p.ProgramPage} component={p.ProgramPage}
/> />
</Dimmer.Dimmable> </React.Fragment>
); );
} }
@ -189,4 +187,4 @@ class DeviceView extends React.Component<DeviceViewProps> {
} }
} }
export default withRouter(injectState(observer(DeviceView))); export default injectState(observer(DeviceView));

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));
}
}; };
} }

2
client/components/MessagesView.tsx

@ -30,7 +30,7 @@ class MessageView extends React.Component<{
if (message.onDismiss) { if (message.onDismiss) {
message.onDismiss(event, data); message.onDismiss(event, data);
} }
uiStore.removeMessage(message); uiStore.messages.remove(message);
}; };
} }

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>

22
client/components/RunSectionForm.tsx

@ -1,6 +1,6 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Form, Header, Icon, Popup, Segment } from "semantic-ui-react"; import { Form, Header, Icon, Segment } from "semantic-ui-react";
import { DurationView, SectionChooser } from "@client/components"; import { DurationView, SectionChooser } from "@client/components";
import { UiStore } from "@client/state"; import { UiStore } from "@client/state";
@ -30,14 +30,8 @@ export default class RunSectionForm extends React.Component<
render() { render() {
const { sectionId, duration } = this.state; const { sectionId, duration } = this.state;
const runButton = (
<Form.Button primary onClick={this.run} disabled={!this.isValid} className="runSectionForm-runButton">
<Icon name="play" />
Run
</Form.Button>
);
return ( return (
<Segment className="runSectionForm"> <Segment>
<Header>Run Section</Header> <Header>Run Section</Header>
<Form> <Form>
<SectionChooser <SectionChooser
@ -51,14 +45,10 @@ export default class RunSectionForm extends React.Component<
duration={duration} duration={duration}
onDurationChange={this.onDurationChange} onDurationChange={this.onDurationChange}
/> />
{ <Form.Button primary onClick={this.run} disabled={!this.isValid}>
this.isValid ? runButton : <Icon name="play" />
<Popup trigger={runButton} on={["click", "hover"]} position="right center"> Run
<Popup.Content> </Form.Button>
Select a section to run and a duration
</Popup.Content>
</Popup>
}
</Form> </Form>
</Segment> </Segment>
); );

10
client/pages/DevicesPage.tsx

@ -1,18 +1,14 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Button, Item } from "semantic-ui-react"; import { Item } from "semantic-ui-react";
import { DeviceView } from "@client/components"; import { DeviceView } from "@client/components";
import { AppState, injectState } from "@client/state"; import { AppState, injectState } from "@client/state";
class DevicesPage extends React.Component<{ appState: AppState }> { class DevicesPage extends React.Component<{ appState: AppState }> {
refreshDevices = () => {
this.props.appState.sprinklersRpc.doAuthenticate();
}
render() { render() {
const { appState } = this.props; const { appState } = this.props;
const userData = appState.userStore.getUserData(); const { userData } = appState.userStore;
let deviceNodes: React.ReactNode; let deviceNodes: React.ReactNode;
if (!userData) { if (!userData) {
deviceNodes = <span>Not logged in</span>; deviceNodes = <span>Not logged in</span>;
@ -25,7 +21,7 @@ class DevicesPage extends React.Component<{ appState: AppState }> {
} }
return ( return (
<React.Fragment> <React.Fragment>
<h1 className="devices-header">Devices <Button icon="refresh" onClick={this.refreshDevices} /></h1> <h1>Devices</h1>
<Item.Group>{deviceNodes}</Item.Group> <Item.Group>{deviceNodes}</Item.Group>
</React.Fragment> </React.Fragment>
); );

46
client/pages/ProgramPage.tsx

@ -2,7 +2,7 @@ import { assign } from "lodash";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as qs from "query-string"; import * as qs from "query-string";
import * as React from "react"; import * as React from "react";
import { RouteComponentProps, withRouter } from "react-router"; import { RouteComponentProps } from "react-router";
import { import {
Button, Button,
CheckboxProps, CheckboxProps,
@ -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(observer(ProgramPage));
export default DecoratedProgramPage; export default DecoratedProgramPage;

51
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);
@ -136,29 +132,27 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
() => () =>
this.connectionState.clientToServer === true && this.connectionState.clientToServer === true &&
this.tokenStore.accessToken.isValid, this.tokenStore.accessToken.isValid,
async () => { this.doAuthenticate() } async () => {
try {
const res = await this.authenticate(
this.tokenStore.accessToken.token!
);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
runInAction("authenticateError", () => {
this.authenticated = false;
});
}
}
); );
} }
async doAuthenticate() {
try {
const res = await this.authenticate(
this.tokenStore.accessToken.token!
);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
runInAction("authenticateError", () => {
this.authenticated = false;
});
}
}
// args must all be JSON serializable // args must all be JSON serializable
async makeDeviceCall( async makeDeviceCall(
deviceId: string, deviceId: string,
@ -195,16 +189,11 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
const id = this.nextRequestId++; const id = this.nextRequestId++;
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => { return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
let timeoutHandle: number; let timeoutHandle: number;
this.responseCallbacks[id] = (response: ws.ServerResponse) => { this.responseCallbacks[id] = response => {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
delete this.responseCallbacks[id]; delete this.responseCallbacks[id];
if (response.result === "success") { if (response.result === "success") {
if (response.method === method) { resolve(response.data);
resolve(response.data as ws.IServerResponseTypes[Method]);
} else {
reject(new s.RpcError("Response method does not match request method", ErrorCode.Internal,
{ requestMethod: method, responseMethod: response.method }));
}
} else { } else {
const { error } = response; const { error } = response;
reject(new s.RpcError(error.message, error.code, error.data)); reject(new s.RpcError(error.message, error.code, error.data));

10
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;
@ -55,7 +47,7 @@ export default class AppState extends TypedEventEmitter<AppEvents> {
async start() { async start() {
configure({ configure({
enforceActions: "observed" enforceActions: true
}); });
syncHistoryWithStore(this.history, this.routerStore); syncHistoryWithStore(this.history, this.routerStore);

18
client/state/UserStore.ts

@ -1,24 +1,20 @@
import { ISprinklersDevice, IUser } from "@common/httpApi"; import { ISprinklersDevice, IUser } from "@common/httpApi";
import { action, IObservableValue, observable } from "mobx"; import { action, observable } from "mobx";
export class UserStore { export class UserStore {
userData: IObservableValue<IUser | null> = observable.box(null); @observable
userData: IUser | null = null;
@action.bound @action.bound
receiveUserData(userData: IUser) { receiveUserData(userData: IUser) {
this.userData.set(userData); this.userData = userData;
}
getUserData(): IUser | null {
return this.userData.get();
} }
findDevice(id: number): ISprinklersDevice | null { findDevice(id: number): ISprinklersDevice | null {
const userData = this.userData.get();
return ( return (
(userData && (this.userData &&
userData.devices && this.userData.devices &&
userData.devices.find(dev => dev.id === id)) || this.userData.devices.find(dev => dev.id === id)) ||
null null
); );
} }

30
client/state/reactContext.tsx

@ -31,18 +31,26 @@ export function ConsumeState({ children }: ConsumeStateProps) {
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>; return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
} }
type Diff<
T extends string | number | symbol,
U extends string | number | symbol
> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T];
type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };
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"
);
}
return <Component {...this.props} appState={state} />;
};
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} />;
}
} }

59
client/styles/DeviceView.scss

@ -1,13 +1,3 @@
.devices-header {
display: flex;
.ui.icon.button {
margin-left: 0.5em;
}
}
$disconnected-color: #d20000;
$connected-color: #13d213;
.device { .device {
.header { .header {
display: flex !important; display: flex !important;
@ -16,29 +6,6 @@ $connected-color: #13d213;
flex-direction: row; flex-direction: row;
} }
} }
.device-body {
position: relative;
margin: 0em -1em 0em -1em;
padding: 0em 1em 1em 1em;
&.blurring.dimmable.dimmed {
// border: $disconnected-color 1px solid;
border-radius: 0.5em;
> :not(.dimmer) {
-webkit-filter: blur(1px) grayscale(0.7);
// filter: blur(1px) grayscale(0.7);
filter: grayscale(0.7);
}
.ui.dimmer {
// border-radius: 1em;
background-color: adjust-color($disconnected-color, $alpha: -0.95);
}
}
}
.ui.grid { .ui.grid {
margin-top: 0; margin-top: 0;
} }
@ -50,11 +17,11 @@ $connected-color: #13d213;
font-weight: lighter; font-weight: lighter;
&.connected { &.connected {
color: $connected-color; color: #13d213;
} }
&.disconnected { &.disconnected {
color: $disconnected-color; color: #d20000;
} }
} }
} }
@ -78,24 +45,6 @@ $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 {
display: inline-block;
&, .ui.disabled.button {
pointer-events: auto !important;
}
} }

2
client/styles/DurationView.scss

@ -4,7 +4,7 @@ $durationInput-labelWidth: 2.5em;
.field .durationInputs { .field .durationInputs {
display: flex; // max-width: 100%; display: flex; // max-width: 100%;
justify-content: flex-start; justify-content: start;
flex-wrap: wrap; flex-wrap: wrap;
margin: -$durationInput-spacing / 2; margin: -$durationInput-spacing / 2;

6
client/styles/ProgramSequenceView.scss

@ -8,16 +8,12 @@
.programSequence-item { .programSequence-item {
list-style-type: none; list-style-type: none;
display: flex; display: flex;
align-items: center;
margin-bottom: 0.5em; margin-bottom: 0.5em;
&.dragging { &.dragging {
z-index: 1010; z-index: 1010;
} }
.fields { .fields {
display: flex; margin: 0em 0em 1em !important;
align-items: center;
margin: 0em !important;
padding: 0em !important;
} }
.ui.icon.button { .ui.icon.button {
height: fit-content; height: fit-content;

7
client/tsconfig.json

@ -10,7 +10,7 @@
], ],
"types": [ "types": [
"webpack-env", "webpack-env",
// "core-js", "core-js",
"node" "node"
], ],
"baseUrl": "..", "baseUrl": "..",
@ -24,10 +24,9 @@
} }
}, },
"include": [ "include": [
"./**/*.ts", "./client/**/*.ts",
"./**/*.tsx" "./client/**/*.tsx"
], ],
"exclude": [],
"references": [{ "references": [{
"path": "../common" "path": "../common"
}] }]

31
client/webpack.config.js

@ -2,9 +2,9 @@ const path = require("path");
const webpack = require("webpack"); const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin");
// const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); const FaviconsWebpackPlugin = require("favicons-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin"); const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin"); const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const DashboardPlugin = require("webpack-dashboard/plugin"); const DashboardPlugin = require("webpack-dashboard/plugin");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer") const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin; .BundleAnalyzerPlugin;
@ -110,7 +110,6 @@ function getConfig(env) {
}, },
cssRule, cssRule,
sassRule, sassRule,
// Process TypeScript with TSC through HappyPack.
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
include: [paths.clientDir, paths.commonDir], include: [paths.clientDir, paths.commonDir],
@ -126,7 +125,7 @@ function getConfig(env) {
loader: "ts-loader", loader: "ts-loader",
options: { options: {
configFile: paths.clientTsConfig, configFile: paths.clientTsConfig,
happyPackMode: true transpileOnly: true
} }
} }
] ]
@ -170,17 +169,21 @@ function getConfig(env) {
} }
: undefined : undefined
}), }),
// new FaviconsWebpackPlugin({ new FaviconsWebpackPlugin({
// logo: path.resolve(paths.clientDir, "images", "favicon-96x96.png"), logo: path.resolve(paths.clientDir, "images", "favicon-96x96.png"),
// emitStatis: false, emitStatis: false,
// prefix: "static/icons-[hash]/" prefix: "static/icons-[hash]/"
// }), }),
// Makes some environment variables available to the JS code, for example: // Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === "production") { ... }. See `./env.js`. // if (process.env.NODE_ENV === "production") { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here. // It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode. // Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(environ.stringified), new webpack.DefinePlugin(environ.stringified),
new CaseSensitivePathsPlugin(), new CaseSensitivePathsPlugin(),
isProd &&
new UglifyJsPlugin({
sourceMap: shouldUseSourceMap
}),
isDev && new webpack.HotModuleReplacementPlugin(), isDev && new webpack.HotModuleReplacementPlugin(),
new ForkTsCheckerWebpackPlugin({ new ForkTsCheckerWebpackPlugin({
checkSyntacticErrors: true, checkSyntacticErrors: true,
@ -235,17 +238,13 @@ 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 },
plugins: plugins, plugins: plugins,
optimization: { optimization: {
namedModules: isProd, namedModules: isProd
minimizer: isProd ? [new TerserPlugin({
sourceMap: shouldUseSourceMap
})] : [],
}, },
devServer: { devServer: {
hot: true, hot: true,
@ -258,7 +257,7 @@ function getConfig(env) {
target: paths.publicUrl target: paths.publicUrl
} }
] ]
}, }
}; };
} }

4
common/ErrorCode.ts

@ -7,10 +7,9 @@ export enum ErrorCode {
BadToken = 105, BadToken = 105,
Unauthorized = 106, Unauthorized = 106,
NoPermission = 107, NoPermission = 107,
NotImplemented = 108,
NotFound = 109, NotFound = 109,
NotUnique = 110,
Internal = 200, Internal = 200,
NotImplemented = 201,
Timeout = 300, Timeout = 300,
ServerDisconnected = 301, ServerDisconnected = 301,
BrokerDisconnected = 302 BrokerDisconnected = 302
@ -23,7 +22,6 @@ export function toHttpStatus(errorCode: ErrorCode): number {
case ErrorCode.Parse: case ErrorCode.Parse:
case ErrorCode.Range: case ErrorCode.Range:
case ErrorCode.InvalidData: case ErrorCode.InvalidData:
case ErrorCode.NotUnique:
return 400; // Bad request return 400; // Bad request
case ErrorCode.Unauthorized: case ErrorCode.Unauthorized:
case ErrorCode.BadToken: case ErrorCode.BadToken:

2
common/jsonRpc/index.ts

@ -133,7 +133,7 @@ export type IResponseHandler<
ResponseTypes, ResponseTypes,
ErrorType, ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes Method extends keyof ResponseTypes = keyof ResponseTypes
> = (response: Response<ResponseTypes, ErrorType, Method>) => void; > = (response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
export interface ResponseHandlers< export interface ResponseHandlers<
ResponseTypes = DefaultResponseTypes, ResponseTypes = DefaultResponseTypes,

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));
} }

2
common/sprinklersRpc/mqtt/index.ts

@ -218,7 +218,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
} }
doUnsubscribe() { doUnsubscribe() {
this.apiClient.client.unsubscribe(this.subscriptions, (err: Error | undefined) => { this.apiClient.client.unsubscribe(this.subscriptions, err => {
if (err) { if (err) {
log.error({ err, id: this.id }, "error unsubscribing to device"); log.error({ err, id: this.id }, "error unsubscribing to device");
} else { } else {

35
common/sprinklersRpc/schema/common.ts

@ -1,29 +1,28 @@
import {Context, custom, ModelSchema, primitive, PropSchema} from 'serializr'; import { ModelSchema, primitive, PropSchema } from "serializr";
import * as s from "..";
import * as s from '..';
export const duration: PropSchema = primitive(); export const duration: PropSchema = primitive();
export const date: PropSchema = custom( export const date: PropSchema = {
(jsDate: Date|null) => jsDate != null ? jsDate.toISOString() : null, serializer: (jsDate: Date | null) =>
(json: any, context: Context, oldValue: any, jsDate != null ? jsDate.toISOString() : null,
done: (err: any, value: any) => void) => { deserializer: (json: any, done) => {
if (json === null) { if (json === null) {
return done(null, null); return done(null, null);
} }
try { try {
done(null, new Date(json)); done(null, new Date(json));
} catch (e) { } catch (e) {
done(e, undefined); done(e, undefined);
} }
}); }
};
export const dateOfYear: ModelSchema<s.DateOfYear> = { export const dateOfYear: ModelSchema<s.DateOfYear> = {
factory: () => new s.DateOfYear(), factory: () => new s.DateOfYear(),
props: { props: {
year: primitive(), year: primitive(),
month: month: primitive(), // this only works if it is represented as a # from 0-12
primitive(), // this only works if it is represented as a # from 0-12
day: primitive() day: primitive()
} }
}; };

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(),
} }
}; };

38
common/sprinklersRpc/schema/list.ts

@ -1,8 +1,8 @@
import {primitive, PropSchema} from 'serializr'; import { primitive, PropSchema } from "serializr";
function invariant(cond: boolean, message?: string) { function invariant(cond: boolean, message?: string) {
if (!cond) { if (!cond) {
throw new Error('[serializr] ' + (message || 'Illegal ServerState')); throw new Error("[serializr] " + (message || "Illegal ServerState"));
} }
} }
@ -11,11 +11,14 @@ function isPropSchema(thing: any) {
} }
function isAliasedPropSchema(propSchema: any) { function isAliasedPropSchema(propSchema: any) {
return typeof propSchema === 'object' && !!propSchema.jsonname; return typeof propSchema === "object" && !!propSchema.jsonname;
} }
function parallel( function parallel(
ar: any[], processor: (item: any, done: any) => void, cb: any) { ar: any[],
processor: (item: any, done: any) => void,
cb: any
) {
if (ar.length === 0) { if (ar.length === 0) {
return void cb(null, []); return void cb(null, []);
} }
@ -40,15 +43,17 @@ function parallel(
export default function list(propSchema: PropSchema): PropSchema { export default function list(propSchema: PropSchema): PropSchema {
propSchema = propSchema || primitive(); propSchema = propSchema || primitive();
invariant(isPropSchema(propSchema), 'expected prop schema as first argument'); invariant(isPropSchema(propSchema), "expected prop schema as first argument");
invariant( invariant(
!isAliasedPropSchema(propSchema), !isAliasedPropSchema(propSchema),
'provided prop is aliased, please put aliases first'); "provided prop is aliased, please put aliases first"
);
return { return {
serializer(ar) { serializer(ar) {
invariant( invariant(
ar && typeof ar.length === 'number' && typeof ar.map === 'function', ar && typeof ar.length === "number" && typeof ar.map === "function",
'expected array (like) object'); "expected array (like) object"
);
return ar.map(propSchema.serializer); return ar.map(propSchema.serializer);
}, },
deserializer(jsonArray, done, context) { deserializer(jsonArray, done, context) {
@ -57,15 +62,14 @@ export default function list(propSchema: PropSchema): PropSchema {
return void done(null, []); return void done(null, []);
} }
if (!Array.isArray(jsonArray)) { if (!Array.isArray(jsonArray)) {
return void done('[serializr] expected JSON array', null); return void done("[serializr] expected JSON array", null);
} }
parallel( parallel(
jsonArray, jsonArray,
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) => (item: any, itemDone: (err: any, targetPropertyValue: any) => void) =>
propSchema.deserializer(item, itemDone, context, undefined), propSchema.deserializer(item, itemDone, context, undefined),
done); done
}, );
beforeDeserialize: undefined as any, }
afterDeserialize: undefined as any,
}; };
} }

4
common/sprinklersRpc/schema/requests.ts

@ -35,9 +35,7 @@ export const updateProgram: ModelSchema<
serializer: data => data, serializer: data => data,
deserializer: (json, done) => { deserializer: (json, done) => {
done(null, json); done(null, json);
}, }
beforeDeserialize: undefined as any,
afterDeserialize: undefined as any,
} }
}); });

5
common/tsconfig.json

@ -14,7 +14,6 @@
} }
}, },
"include": [ "include": [
"./**/*.ts", "./**/*.ts"
], ]
"exclude": []
} }

8
docker-compose.dev.yml

@ -12,7 +12,6 @@ services:
- "8080:8080" - "8080:8080"
- "8081:8081" - "8081:8081"
volumes: volumes:
- ./bin:/app/bin
- ./client:/app/client - ./client:/app/client
- ./common:/app/common - ./common:/app/common
- ./server:/app/server - ./server:/app/server
@ -37,13 +36,8 @@ services:
- "1883:1883" - "1883:1883"
database: database:
image: "postgres:11" image: "postgres:11-alpine"
ports: ports:
- "5432:5432" - "5432:5432"
volumes:
- data-volume:/var/lib/postgres/data
environment: environment:
- POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV - POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV
volumes:
data-volume:

2
docker-compose.yml

@ -20,6 +20,6 @@ services:
# Must specify JWT_SECRET and MQTT_URL # Must specify JWT_SECRET and MQTT_URL
database: database:
image: "postgres:11" image: "postgres:11-alpine"
environment: environment:
- POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV - POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV

146
package.json

@ -43,111 +43,115 @@
"commands": "./dist/commands" "commands": "./dist/commands"
}, },
"dependencies": { "dependencies": {
"@oclif/command": "^1.5.0", "@oclif/command": "^1.5.6",
"@oclif/config": "^1.7.4", "@oclif/config": "^1.9.0",
"@oclif/plugin-help": "^2.1.1", "@oclif/plugin-help": "^2.1.4",
"bcrypt": "^3.0.0", "@types/split2": "^2.1.6",
"bcrypt": "^3.0.2",
"body-parser": "^1.18.3", "body-parser": "^1.18.3",
"chalk": "^2.4.1", "chalk": "^2.4.1",
"cli-ux": "^5.3.1", "cli-ux": "^4.9.3",
"express": "^4.16.3", "express": "^4.16.4",
"express-pino-logger": "^4.0.0",
"express-promise-router": "^3.0.3", "express-promise-router": "^3.0.3",
"globby": "^10.0.1", "jsonwebtoken": "^8.4.0",
"jsonwebtoken": "^8.3.0", "lodash": "^4.17.11",
"lodash": "^4.17.10", "mobx": "^5.7.0",
"mobx": "^5.1.0", "mobx-utils": "^5.1.0",
"mobx-utils": "^5.0.1",
"module-alias": "^2.1.0", "module-alias": "^2.1.0",
"moment": "^2.22.2", "moment": "^2.22.2",
"mqtt": "^3.0.0", "mqtt": "^2.18.8",
"pg": "^7.4.3", "pg": "^7.7.1",
"pino": "^5.4.0", "pino": "^5.10.0",
"pino-http": "^4.2.0",
"pump": "^3.0.0", "pump": "^3.0.0",
"reflect-metadata": "^0.1.12", "reflect-metadata": "^0.1.12",
"serializr": "^1.3.0", "serializr": "^1.3.0",
"split2": "^3.0.0", "split2": "^3.0.0",
"terser-webpack-plugin": "^1.3.0", "through2": "^3.0.0",
"through2": "^3.0.1", "typeorm": "^0.2.9",
"typeorm": "^0.2.7", "ws": "^6.1.2"
"ws": "^7.1.1"
}, },
"devDependencies": { "devDependencies": {
"@hot-loader/react-dom": "^16.8.6", "@types/async": "^2.0.50",
"@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",
"@types/core-js": "^2.5.0", "@types/core-js": "^2.5.0",
"@types/express": "^4.16.0", "@types/express": "^4.16.0",
"@types/jsonwebtoken": "^8.3.2", "@types/jsonwebtoken": "^8.3.0",
"@types/lodash": "^4.14.116", "@types/lodash": "^4.14.119",
"@types/module-alias": "^2.0.0", "@types/module-alias": "^2.0.0",
"@types/node": "^11.11.3", "@types/node": "^10.12.12",
"@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.7",
"@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.1",
"@types/react": "^16.7.13", "@types/react": "16.7.13",
"@types/react-dom": "^16.0.11", "@types/react-dom": "16.0.11",
"@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.1",
"@types/react-sortable-hoc": "^0.6.4", "@types/react-sortable-hoc": "^0.6.4",
"@types/split2": "^2.1.6", "@types/through2": "^2.0.34",
"@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.1",
"async": "^3.1.0", "async": "^2.6.1",
"autoprefixer": "^9.1.3", "autoprefixer": "^9.4.2",
"cache-loader": "^4.1.0", "cache-loader": "^1.2.5",
"case-sensitive-paths-webpack-plugin": "^2.1.2", "case-sensitive-paths-webpack-plugin": "^2.1.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"css-loader": "^3.1.0", "css-loader": "^2.0.0",
"dotenv": "^8.0.0", "dotenv": "^6.2.0",
"favicons-webpack-plugin": "^0.0.9", "favicons-webpack-plugin": "^0.0.9",
"file-loader": "^4.1.0", "file-loader": "^2.0.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"fork-ts-checker-webpack-plugin": "^1.4.3", "fork-ts-checker-webpack-plugin": "^0.5.1",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.8.0", "mini-css-extract-plugin": "^0.5.0",
"mobx-react": "^6.1.1", "mobx-react": "^5.4.2",
"mobx-react-devtools": "^6.0.3", "mobx-react-devtools": "^6.0.3",
"mobx-react-router": "^4.0.4", "mobx-react-router": "^4.0.5",
"node-sass": "^4.9.3", "node-sass": "^4.11.0",
"nodemon": "^1.18.4", "nodemon": "^1.18.8",
"npm-run-all": "^4.1.3", "npm-run-all": "^4.1.5",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"postcss-flexbugs-fixes": "^4.1.0", "postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.4.0",
"promise": "^8.0.1", "promise": "^8.0.2",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"query-string": "^6.1.0", "query-string": "^6.2.0",
"react": "^16.8.0", "react": "16.6.3",
"react-dev-utils": "^9.0.1", "react-dev-utils": "^6.1.1",
"react-dom": "^16.6.3", "react-dom": "16.6.3",
"react-hot-loader": "^4.3.5", "react-hot-loader": "^4.3.12",
"react-router": "^5.0.1", "react-router": "^4.3.1",
"react-router-dom": "^5.0.1", "react-router-dom": "^4.3.1",
"react-sortable-hoc": "^1.9.1", "react-sortable-hoc": "^0.8.4",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"semantic-ui-css": "^2.3.3", "semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.87.3", "semantic-ui-react": "^0.84.0",
"source-map-loader": "^0.2.4", "source-map-loader": "^0.2.4",
"style-loader": "^0.23.0", "style-loader": "^0.23.1",
"thread-loader": "^2.1.2", "thread-loader": "^1.2.0",
"ts-loader": "^6.0.4", "ts-loader": "^5.3.1",
"tslint": "^5.11.0", "tslint": "^5.11.0",
"tslint-config-prettier": "^1.15.0", "tslint-config-prettier": "^1.17.0",
"tslint-consistent-codestyle": "^1.13.3", "tslint-consistent-codestyle": "^1.14.1",
"tslint-react": "^4.0.0", "tslint-react": "^3.6.0",
"typescript": "^3.0.3", "typescript": "^3.2.2",
"url-loader": "^2.1.0", "uglify-es": "^3.3.9",
"webpack": "^4.17.1", "uglifyjs-webpack-plugin": "^2.0.1",
"webpack-bundle-analyzer": "^3.3.2", "url-loader": "^1.1.2",
"webpack-cli": "^3.1.0", "webpack": "^4.27.1",
"webpack-dashboard": "^3.0.7", "webpack-bundle-analyzer": "^3.0.3",
"webpack-dev-server": "^3.1.7" "webpack-cli": "^3.1.2",
"webpack-dashboard": "^2.0.0",
"webpack-dev-server": "^3.1.10"
},
"resolutions": {
"**/@types/react": "16.7.13",
"**/@types/react-dom": "16.0.11",
"**/react": "16.6.3",
"**/react-dom": "16.6.3"
} }
} }

23
server/Database.ts

@ -3,7 +3,7 @@ import { Connection, createConnection, getConnectionOptions } from "typeorm";
import logger from "@common/logger"; import logger from "@common/logger";
import { SprinklersDevice, User } from "./entities"; import { User } from "./entities";
import { SprinklersDeviceRepository, UserRepository } from "./repositories/"; import { SprinklersDeviceRepository, UserRepository } from "./repositories/";
export class Database { export class Database {
@ -24,30 +24,25 @@ export class Database {
Object.assign(options, { Object.assign(options, {
entities: [path.resolve(__dirname, "entities", "*.js")] entities: [path.resolve(__dirname, "entities", "*.js")]
}); });
if (options.synchronize) {
logger.warn("synchronizing database schema");
}
this._conn = await createConnection(options); this._conn = await createConnection(options);
this.users = this._conn.getCustomRepository(UserRepository); this.users = this._conn.getCustomRepository(UserRepository);
this.sprinklersDevices = this._conn.getCustomRepository( this.sprinklersDevices = this._conn.getCustomRepository(
SprinklersDeviceRepository SprinklersDeviceRepository
); );
logger.info("connected to database");
} }
async disconnect() { async disconnect() {
if (this._conn) { if (this._conn) {
await this._conn.close(); return this._conn.close();
logger.info("disconnected from database");
} }
} }
async insertTestData() { async insertTestData() {
const NUM = 50; const NUM = 100;
const users: User[] = []; const users: User[] = [];
for (let i = 0; i < NUM; i++) { for (let i = 0; i < NUM; i++) {
const username = "alex" + i; const username = "alex" + i;
let user = await this.users.findByUsername(username, { devices: true }); let user = await this.users.findByUsername(username);
if (!user) { if (!user) {
user = await this.users.create({ user = await this.users.create({
name: "Alex Mikhalev" + i, name: "Alex Mikhalev" + i,
@ -58,8 +53,6 @@ export class Database {
users.push(user); users.push(user);
} }
const devices: SprinklersDevice[] = [];
for (let i = 0; i < NUM; i++) { for (let i = 0; i < NUM; i++) {
const name = "Test" + i; const name = "Test" + i;
let device = await this.sprinklersDevices.findByName(name); let device = await this.sprinklersDevices.findByName(name);
@ -70,17 +63,13 @@ export class Database {
name, name,
deviceId: "grinklers" + (i === 1 ? "" : i) deviceId: "grinklers" + (i === 1 ? "" : i)
}); });
devices.push(device); await this.sprinklersDevices.save(device);
for (let j = 0; j < 5; j++) { for (let j = 0; j < 5; j++) {
const userIdx = (i + j * 10) % NUM; const userIdx = (i + j * 10) % NUM;
const user = users[userIdx]; const user = users[userIdx];
if (!user.devices) { user.devices = (user.devices || []).concat([device]);
user.devices = [];
}
user.devices.push(device);
} }
} }
await this.sprinklersDevices.save(devices);
logger.info("inserted/updated devices"); logger.info("inserted/updated devices");
await this.users.save(users); await this.users.save(users);

21
server/ManageCommand.ts

@ -1,21 +0,0 @@
import Command from "@oclif/command";
import { Database, ServerState } from ".";
export default abstract class ManageCommand extends Command {
state!: ServerState;
database!: Database;
async connect() {
this.state = new ServerState();
await this.state.startDatabase();
this.database = this.state.database;
}
async finally(e: Error | undefined) {
if (this.state) {
await this.state.stopDatabase();
}
await super.finally(e);
}
}

14
server/UniqueConstraintError.ts

@ -1,14 +0,0 @@
import { QueryFailedError } from "typeorm";
import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode";
export default class UniqueConstraintError extends ApiError {
static is(err: any): err is QueryFailedError {
return err && err.name === "QueryFailedError" && (err as any).code === 23505; // unique constraint error
}
constructor(err: QueryFailedError) {
super(`Unsatisfied unique constraint: ${(err as any).detail}`, ErrorCode.NotUnique, err);
}
}

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,

161
server/commands/device.ts

@ -1,161 +0,0 @@
import { flags } from "@oclif/command";
import { ux } from "cli-ux";
import { capitalize } from "lodash";
import { FindConditions } from "typeorm";
import ManageCommand from "../ManageCommand";
import { Input } from "@oclif/parser/lib/flags";
import { SprinklersDevice, User } from "@server/entities";
type DeviceFlags = (typeof DeviceCommand)["flags"] extends Input<infer F>
? F
: never;
type Action = "show" | "delete" | "add-user" | "remove-user";
const VALID_ACTIONS: Action[] = [ "show", "delete", "add-user", "remove-user" ];
// tslint:disable:no-shadowed-variable
export default class DeviceCommand extends ManageCommand {
static description = "Manage devices";
static flags = {
show: flags.boolean({
char: "s",
exclusive: ["add-user", "delete"],
description: "Show devices(s)",
}),
"add-user": flags.boolean({
char: "a",
exclusive: ["show", "delete"],
description: "Add a user as owning this device (specify --username)"
}),
"remove-user": flags.boolean({
char: "r",
exclusive: ["add-user", "show", "delete"],
description: "Remove a user as owning this device (specify --username)"
}),
delete: flags.boolean({
char: "d",
exclusive: ["show", "add-user"],
description: "Delete a user (by --id or --username)"
}),
id: flags.integer({
description: "The id of the device to update or delete",
}),
name: flags.string({
description: "The name of the device, when creating or updating"
}),
deviceId: flags.string({
description: "The deviceId of the device, when creating or updating"
}),
username: flags.string({
description: "Specify a username for --show or --add-user"
}),
};
getAction(flags: DeviceFlags): Action {
for (const action of VALID_ACTIONS) {
if (flags[action]) {
return action;
}
}
const actionFlags = VALID_ACTIONS.map(action => `--${action}`);
this.error(`Must specify an action (${actionFlags.join(', ')})`, {
exit: false,
});
return this._help();
}
getFindConditions(flags: DeviceFlags, action: Action): FindConditions<SprinklersDevice> {
const whereClause: FindConditions<SprinklersDevice> = {};
if (flags.id) {
whereClause.id = flags.id;
}
if (flags.name) {
whereClause.name = flags.name;
}
if (flags.deviceId) {
whereClause.deviceId = flags.deviceId;
}
if (false) {
this.error(`Must specify --id to ${action}`, {
exit: false
});
return this._help();
}
return whereClause;
}
async getOrDeleteDevice(flags: DeviceFlags, action: Action): Promise<SprinklersDevice | never> {
const findConditions = this.getFindConditions(flags, action);
if (action === "delete") {
const result = await this.database.sprinklersDevices.delete(findConditions);
if (result.raw[1] > 0) {
this.log(`Deleted device`);
} else {
this.error("Did not find device to delete");
}
return this.exit();
} else {
const device = await this.database.sprinklersDevices.findOne(findConditions);
if (!device) {
return this.error(`The specified device does not exist`);
}
return device;
}
}
async run() {
const parseResult = this.parse(DeviceCommand);
const flags = parseResult.flags;
const action = this.getAction(flags);
await this.connect();
if (flags.show) {
const findConditions = this.getFindConditions(flags, action);
let query = this.database.sprinklersDevices.createQueryBuilder("device")
.leftJoinAndSelect("device.users", "user")
.where(findConditions);
if (flags.username) {
query = query.where("user.username = :username", { username: flags.username });
}
const devices = await query.getMany();
if (devices.length === 0) {
this.log("No sprinklers devices found");
return 1;
}
this.log("Devices: ")
for (const device of devices) {
this.log(JSON.stringify(device, null, " "));
}
return;
}
const device = await this.getOrDeleteDevice(flags, action);
if (flags["add-user"] || flags["remove-user"]) {
if (!flags.username) {
return this.error("Must specify --username for --add-user")
}
const user = await this.database.users.findByUsername(flags.username);
if (!user) {
return this.error(`Could not find user with username '${flags.username}'`);
}
const query = this.database.sprinklersDevices.createQueryBuilder()
.relation("users")
.of(device);
if (flags["add-user"]) {
await query.add(user);
this.log(`Added user '${user.username}' to device '${device.name}`);
} else {
await query.remove(user);
this.log(`Removed user '${user.username}' from device '${device.name}`);
}
}
}
}

11
server/commands/manage.ts

@ -0,0 +1,11 @@
import Command from "@oclif/command";
import { createApp, ServerState, WebSocketApi } from "../";
import log from "@common/logger";
export default class ManageCommand extends Command {
run(): Promise<any> {
throw new Error("Method not implemented.");
}
}

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();
}
}
}

154
server/commands/user.ts

@ -1,154 +0,0 @@
import { flags } from "@oclif/command";
import { ux } from "cli-ux";
import { capitalize } from "lodash";
import { FindConditions } from "typeorm";
import ManageCommand from "../ManageCommand";
import { Input } from "@oclif/parser/lib/flags";
import { User } from "@server/entities";
type UserFlags = (typeof UserCommand)["flags"] extends Input<infer F>
? F
: never;
type Action = "show" | "create" | "update" | "delete";
// tslint:disable:no-shadowed-variable
export default class UserCommand extends ManageCommand {
static description = "Manage users";
static flags = {
show: flags.boolean({
char: "s",
exclusive: ["create", "update", "delete"],
description: "Show user(s)",
}),
create: flags.boolean({
char: "c",
exclusive: ["update", "delete", "id"],
dependsOn: ["username"],
description: "Create a new user"
}),
update: flags.boolean({
char: "u",
exclusive: ["create", "delete"],
description: "Update an existing user (by --id or --username)"
}),
delete: flags.boolean({
char: "d",
exclusive: ["create", "update"],
description: "Delete a user (by --id or --username)"
}),
id: flags.integer({
description: "The id of the user to update or delete",
}),
username: flags.string({
description: "The username of the user to create or update"
}),
name: flags.string({
description: "The name of the user, when creating or updating"
}),
passwordPrompt: flags.boolean({
char: "p",
description:
"Prompts for the password of the user when creating or updating"
})
};
getAction(flags: UserFlags): Action {
if (flags.show) return "show";
else if (flags.create) return "create";
else if (flags.update) return "update";
else if (flags.delete) return "delete";
else {
this.error("Must specify an action (--show, --create, --update, --delete)", {
exit: false
});
return this._help();
}
}
getFindConditions(flags: UserFlags, action: Action): FindConditions<User> {
if (flags.id != null) {
return { id: flags.id };
} else if (flags.username) {
return { username: flags.username };
} else {
this.error(`Must specify either --id or --username to ${action}`, {
exit: false
});
return this._help();
}
}
async getOrDeleteUser(flags: UserFlags, action: Action): Promise<User | never> {
if (action === "create") {
return this.database.users.create();
} else {
const findConditions = this.getFindConditions(flags, action);
if (action === "delete") {
const result = await this.database.users.delete(findConditions);
this.log(`Deleted user: `, result);
return this.exit();
} else {
const user = await this.database.users.findOneUser(findConditions);
if (!user) {
return this.error(`The specified user does not exist`);
}
return user;
}
}
}
async run() {
const parseResult = this.parse(UserCommand);
const flags = parseResult.flags;
const action = this.getAction(flags);
await this.connect();
if (flags.show) {
let whereClause: FindConditions<User> = {};
if (flags.id) {
whereClause.id = flags.id;
}
if (flags.username) {
whereClause.username = flags.username;
}
const users = await this.database.users.find({ where: whereClause });
if (users.length == 0) {
this.error("No users found");
}
this.log("Users: ")
for (const user of users) {
this.log(JSON.stringify(user.toJSON()));
}
return;
}
const user = await this.getOrDeleteUser(flags, action);
if (flags.username && (flags.create || flags.id)) {
user.username = flags.username;
}
if (flags.name) {
user.name = flags.name;
}
if (flags.passwordPrompt || flags.create) {
const password = await ux.prompt("Enter a password to assign the user", {
type: "hide"
});
await user.setPassword(password);
}
try {
await this.database.users.save(user);
this.log(`${capitalize(action)}d user id ${user.id} (${user.username})`);
} catch (e) {
throw e;
}
}
}

2
server/entities/SprinklersDevice.ts

@ -14,7 +14,7 @@ export class SprinklersDevice implements ISprinklersDevice {
@Column() @Column()
name: string = ""; name: string = "";
@ManyToMany(type => User, user => user.devices) @ManyToMany(type => User)
users: User[] | undefined; users: User[] | undefined;
constructor(data?: Partial<SprinklersDevice>) { constructor(data?: Partial<SprinklersDevice>) {

6
server/entities/User.ts

@ -3,7 +3,6 @@ import { omit } from "lodash";
import { import {
Column, Column,
Entity, Entity,
Index,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
PrimaryGeneratedColumn PrimaryGeneratedColumn
@ -19,8 +18,7 @@ export class User implements IUser {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column() @Column({ unique: true })
@Index("user_username_unique", { unique: true })
username: string = ""; username: string = "";
@Column() @Column()
@ -29,7 +27,7 @@ export class User implements IUser {
@Column() @Column()
passwordHash: string = ""; passwordHash: string = "";
@ManyToMany(type => SprinklersDevice, device => device.users) @ManyToMany(type => SprinklersDevice)
@JoinTable({ name: "user_sprinklers_device" }) @JoinTable({ name: "user_sprinklers_device" })
devices: SprinklersDevice[] | undefined; devices: SprinklersDevice[] | undefined;

45
server/express/api/devices.ts

@ -1,13 +1,10 @@
import { Request } from "express";
import PromiseRouter from "express-promise-router"; import PromiseRouter from "express-promise-router";
import { serialize } from "serializr"; import { serialize } from "serializr";
import ApiError from "@common/ApiError"; import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode"; import { ErrorCode } from "@common/ErrorCode";
import * as schema from "@common/sprinklersRpc/schema"; import * as schema from "@common/sprinklersRpc/schema";
import { DeviceToken } from "@common/TokenClaims";
import { generateDeviceToken } from "@server/authentication"; import { generateDeviceToken } from "@server/authentication";
import { SprinklersDevice } from "@server/entities";
import { verifyAuthorization } from "@server/express/verifyAuthorization"; import { verifyAuthorization } from "@server/express/verifyAuthorization";
import { ServerState } from "@server/state"; import { ServerState } from "@server/state";
@ -33,7 +30,7 @@ function randomDeviceId(): string {
export function devices(state: ServerState) { export function devices(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
async function verifyUserDevice(req: Request): Promise<SprinklersDevice> { router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
const token = req.token!; const token = req.token!;
const userId = token.aud; const userId = token.aud;
const deviceId = req.params.deviceId; const deviceId = req.params.deviceId;
@ -47,39 +44,12 @@ export function devices(state: ServerState) {
ErrorCode.NoPermission ErrorCode.NoPermission
); );
} }
return userDevice;
}
router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
const deviceInfo = await verifyUserDevice(req);
res.send({
id: deviceInfo.id, deviceId: deviceInfo.deviceId, name: deviceInfo.name
})
});
router.get("/:deviceId/data", verifyAuthorization(), async (req, res) => {
await verifyUserDevice(req);
const device = state.mqttClient.acquireDevice(req.params.deviceId); const device = state.mqttClient.acquireDevice(req.params.deviceId);
const j = serialize(schema.sprinklersDevice, device); const j = serialize(schema.sprinklersDevice, device);
res.send(j); res.send(j);
device.release(); device.release();
}); });
router.post("/:deviceId/generate_token",
verifyAuthorization(), async (req, res) => {
const device = await verifyUserDevice(req);
if (!device.deviceId) {
throw new ApiError(
"A token cannot be granted for a device with no id",
ErrorCode.BadRequest,
)
}
const token = await generateDeviceToken(device.id, device.deviceId);
res.send({
token,
});
});
router.post( router.post(
"/register", "/register",
verifyAuthorization({ verifyAuthorization({
@ -106,19 +76,8 @@ export function devices(state: ServerState) {
type: "device" type: "device"
}), }),
async (req, res) => { async (req, res) => {
const token: DeviceToken = req.token! as any;
const deviceId = token.aud;
const devs = await state.database.sprinklersDevices.count({
deviceId
});
if (devs === 0) {
throw new ApiError("deviceId not found", ErrorCode.NotFound);
}
const clientId = `device-${deviceId}`;
res.send({ res.send({
mqttUrl: state.mqttUrl, url: state.mqttUrl
deviceId,
clientId,
}); });
} }
); );

11
server/express/requestLogger.ts

@ -1,7 +1,8 @@
import log from "@common/logger"; import log from "@common/logger";
import pinoHttp = require("pino-http"); import expressPinoLogger = require("express-pino-logger");
import * as pino from "pino";
export default pinoHttp({ const l = pino();
logger: log, pino(l);
useLevel: "debug",
} as pinoHttp.Options); export default expressPinoLogger(log);

1
server/index.ts

@ -5,6 +5,5 @@ import "./env";
import "./configureLogger"; import "./configureLogger";
export { ServerState } from "./state"; export { ServerState } from "./state";
export { Database } from "./Database";
export { createApp } from "./express"; export { createApp } from "./express";
export { WebSocketApi } from "./sprinklersRpc/"; export { WebSocketApi } from "./sprinklersRpc/";

16
server/repositories/SprinklersDeviceRepository.ts

@ -1,7 +1,6 @@
import { DeepPartial, EntityRepository, Repository, SaveOptions } from "typeorm"; import { EntityRepository, Repository } from "typeorm";
import { SprinklersDevice, User } from "@server/entities"; import { SprinklersDevice, User } from "@server/entities";
import UniqueConstraintError from "@server/UniqueConstraintError";
@EntityRepository(SprinklersDevice) @EntityRepository(SprinklersDevice)
export class SprinklersDeviceRepository extends Repository<SprinklersDevice> { export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
@ -40,17 +39,4 @@ export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
} }
return user.devices![0]; return user.devices![0];
} }
save<T extends DeepPartial<SprinklersDevice>>(entities: T[], options?: SaveOptions): Promise<T[]>;
save<T extends DeepPartial<SprinklersDevice>>(entity: T, options?: SaveOptions): Promise<T>;
async save(entity: any, options?: SaveOptions): Promise<any> {
try {
return await super.save(entity, options);
} catch (e) {
if (UniqueConstraintError.is(e)) {
throw new UniqueConstraintError(e);
}
throw e;
}
}
} }

36
server/repositories/UserRepository.ts

@ -1,49 +1,33 @@
import { EntityRepository, FindConditions, FindOneOptions, Repository } from "typeorm"; import { EntityRepository, FindOneOptions, Repository } from "typeorm";
import { User } from "@server/entities"; import { User } from "@server/entities";
export interface FindUserOptions extends FindOneOptions<User> { export interface FindUserOptions {
devices: boolean; devices: boolean;
} }
function computeOptions( function applyDefaultOptions(
options?: Partial<FindUserOptions> options?: Partial<FindUserOptions>
): FindOneOptions<User> { ): FindOneOptions<User> {
const opts: FindUserOptions = { devices: false, ...options }; const opts: FindUserOptions = { devices: false, ...options };
const { devices, ...rest } = opts; const relations = [opts.devices && "devices"].filter(Boolean) as string[];
const relations = [devices && "devices"].filter(Boolean) as string[]; return { relations };
return { relations, ...rest };
} }
@EntityRepository(User) @EntityRepository(User)
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<User> {
findAll(options?: Partial<FindUserOptions>) { findAll(options?: Partial<FindUserOptions>) {
const opts = computeOptions(options); const opts = applyDefaultOptions(options);
return super.find(opts); return super.find(opts);
} }
findOneUser(conditions: FindConditions<User>, options?: Partial<FindUserOptions>) {
const opts = computeOptions(options);
return super.findOne(conditions, opts);
}
findById(id: number, options?: Partial<FindUserOptions>) { findById(id: number, options?: Partial<FindUserOptions>) {
return this.findOneUser({ id }, options); const opts = applyDefaultOptions(options);
return super.findOne(id, opts);
} }
findByUsername(username: string, options?: Partial<FindUserOptions>) { findByUsername(username: string, options?: Partial<FindUserOptions>) {
return this.findOneUser({ username }, options); const opts = applyDefaultOptions(options);
return this.findOne({ username }, opts);
} }
// async checkAndSave(entity: User): Promise<User> {
// return this.manager.transaction(manager => {
// let query = manager.createQueryBuilder<User>(User, "user", manager.queryRunner!);
// if (entity.id != null) {
// query = query.where("user.id <> :id", { id: entity.id })
// }
// query
// return manager.save(entity);
// });
// }
} }

13
server/state.ts

@ -23,20 +23,13 @@ export class ServerState {
async startDatabase() { async startDatabase() {
await this.database.connect(); await this.database.connect();
logger.info("connected to database");
if (process.env.INSERT_TEST_DATA) { if (process.env.INSERT_TEST_DATA) {
try { await this.database.insertTestData();
await this.database.insertTestData(); logger.info("inserted test data");
logger.info("inserted test data");
} catch (e) {
logger.error(e, "error inserting test data");
}
} }
} }
async stopDatabase() {
await this.database.disconnect();
}
async startMqtt() { async startMqtt() {
this.mqttClient.username = SUPERUSER; this.mqttClient.username = SUPERUSER;

41
server/tsconfig.json

@ -2,25 +2,24 @@
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../dist", "outDir": "../dist",
"sourceMap": true, // "sourceMap": true,
"emitDecoratorMetadata": true, // "emitDecoratorMetadata": true,
"strict": true, // "strict": true,
"allowJs": true, // "allowJs": true,
"baseUrl": "..", // "baseUrl": "..",
"paths": { // "paths": {
"@common/*": [ // "@common/*": [
"./common/*" // "./common/*"
], // ],
"@server/*": [ // "@server/*": [
"./server/*" // "./server/*"
] // ]
} // }
}, }//,
"references": [{ // "references": [{
"path": "../common" // "path": "../common"
}], // }],
"include": [ // "include": [
"./**/*.ts" // "./**/*.ts"
], // ]
"exclude": []
} }

7
server/types/express-pino-logger.d.ts vendored

@ -0,0 +1,7 @@
declare module "express-pino-logger" {
import { Logger } from "pino";
import { ErrorRequestHandler } from "express";
function makeLogger(logger: Logger): ErrorRequestHandler;
export = makeLogger;
}

18
tsconfig.json

@ -9,20 +9,6 @@
"types": [ "types": [
"node" "node"
], ],
"strict": true, "strict": true
"baseUrl": ".", }
"paths": {
"@common/*": [
"./common/*"
],
"@client/*": [
"./client/*"
]
}
},
"exclude": [
"./client",
"./common",
"./server"
]
} }

4676
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save