Compare commits

...

32 Commits

Author SHA1 Message Date
Alex Mikhalev f6d6ef7c0c Show next run time for programs 6 years ago
Alex Mikhalev 89562b11b0 Re-auth on visibility change 6 years ago
Alex Mikhalev d675342908 Give more room while editing programs 6 years ago
Alex Mikhalev 39323369bb Use hooks for context 6 years ago
Alex Mikhalev 7bcf145f6d Longer refresh token lifetime 6 years ago
Alex Mikhalev e5fd8ce364 Better DurationView 6 years ago
Alex Mikhalev 51d7c78cac Fix react hot loader 6 years ago
Alex Mikhalev e8c3f710d6 Update react version 6 years ago
Alex Mikhalev 6928b5a8a6 Add token command 6 years ago
Alex Mikhalev 9005a91d17 Fix programpage close behavior 6 years ago
Alex Mikhalev f328e5c2e2 Lots of improvements 6 years ago
Alex Mikhalev 6e95d091ae A few little fix-ups 6 years ago
Alex Mikhalev d598eaaa1d Remove old definition 6 years ago
Alex Mikhalev ac27c0a9ec Fix tsconfigs 6 years ago
Alex Mikhalev 097e5a246c Use a volume for postgres data in docker-compose.dev.yml 6 years ago
Alex Mikhalev dcce9edc34 Add tooltip for why you can't run a section 6 years ago
Alex Mikhalev b987e49beb Fix typeorm relations 6 years ago
Alex Mikhalev 5a90ae54e5 Add device command and touch up user command 6 years ago
Alex Mikhalev fd4f4025de Fix database test data 6 years ago
Alex Mikhalev 9b54655344 Fix list schemas 6 years ago
Alex Mikhalev 733814ae7e Dim device when disconnected 6 years ago
Alex Mikhalev 23527dd817 Add refresh button on DevicesPage 6 years ago
Alex Mikhalev 9ef81b015b Switch to pino-http 6 years ago
Alex Mikhalev e391c26b95 Add user --show command 6 years ago
Alex Mikhalev 9b959ff57b Update dependencies 6 years ago
Alex Mikhalev 8c49cabc35 add clientId to device connect endpoint 6 years ago
Alex Mikhalev 0aa587551f added devices generate_token route 6 years ago
Alex Mikhalev ddba17a651 actually fixed docker build 6 years ago
Alex Mikhalev 6f6a1bbd32 fix docker build and update react 6 years ago
Alex Mikhalev b739a6772c remove trash 6 years ago
Alex Mikhalev 1dd446c6fc fix: user command stuff 6 years ago
Alex Mikhalev 18c35ac8b9 feat: Add user command to manage users 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. 28
      client/webpack.config.js
  22. 4
      common/ErrorCode.ts
  23. 2
      common/TypedEventEmitter.ts
  24. 2
      common/browserLogger.ts
  25. 2
      common/jsonRpc/index.ts
  26. 7
      common/sprinklersRpc/Program.ts
  27. 2
      common/sprinklersRpc/mqtt/MqttProgram.ts
  28. 2
      common/sprinklersRpc/mqtt/index.ts
  29. 35
      common/sprinklersRpc/schema/common.ts
  30. 5
      common/sprinklersRpc/schema/index.ts
  31. 38
      common/sprinklersRpc/schema/list.ts
  32. 4
      common/sprinklersRpc/schema/requests.ts
  33. 5
      common/tsconfig.json
  34. 8
      docker-compose.dev.yml
  35. 2
      docker-compose.yml
  36. 80
      package.json
  37. 23
      server/Database.ts
  38. 21
      server/ManageCommand.ts
  39. 14
      server/UniqueConstraintError.ts
  40. 2
      server/authentication.ts
  41. 161
      server/commands/device.ts
  42. 11
      server/commands/manage.ts
  43. 32
      server/commands/token.ts
  44. 154
      server/commands/user.ts
  45. 2
      server/entities/SprinklersDevice.ts
  46. 6
      server/entities/User.ts
  47. 45
      server/express/api/devices.ts
  48. 11
      server/express/requestLogger.ts
  49. 1
      server/index.ts
  50. 16
      server/repositories/SprinklersDeviceRepository.ts
  51. 36
      server/repositories/UserRepository.ts
  52. 13
      server/state.ts
  53. 3
      server/tsconfig.json
  54. 7
      server/types/express-pino-logger.d.ts
  55. 18
      tsconfig.json
  56. 6447
      yarn.lock

3

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

3
Dockerfile.dev

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

6
client/App.tsx

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

12
client/components/DeviceView.tsx

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

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

2
client/components/MessagesView.tsx

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

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>

22
client/components/RunSectionForm.tsx

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

10
client/pages/DevicesPage.tsx

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

46
client/pages/ProgramPage.tsx

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

51
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);
@ -132,27 +136,29 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -132,27 +136,29 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
() =>
this.connectionState.clientToServer === true &&
this.tokenStore.accessToken.isValid,
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 () => { this.doAuthenticate() }
);
}
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
async makeDeviceCall(
deviceId: string,
@ -189,11 +195,16 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -189,11 +195,16 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
const id = this.nextRequestId++;
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
let timeoutHandle: number;
this.responseCallbacks[id] = response => {
this.responseCallbacks[id] = (response: ws.ServerResponse) => {
clearTimeout(timeoutHandle);
delete this.responseCallbacks[id];
if (response.result === "success") {
resolve(response.data);
if (response.method === method) {
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 {
const { error } = response;
reject(new s.RpcError(error.message, error.code, error.data));

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

18
client/state/UserStore.ts

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

30
client/state/reactContext.tsx

@ -31,26 +31,18 @@ export function ConsumeState({ children }: ConsumeStateProps) { @@ -31,26 +31,18 @@ export function ConsumeState({ children }: ConsumeStateProps) {
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 }>(
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"
);
}
return <Component {...this.props} appState={state} />;
};
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} />;
}
}

59
client/styles/DeviceView.scss

@ -1,3 +1,13 @@ @@ -1,3 +1,13 @@
.devices-header {
display: flex;
.ui.icon.button {
margin-left: 0.5em;
}
}
$disconnected-color: #d20000;
$connected-color: #13d213;
.device {
.header {
display: flex !important;
@ -6,6 +16,29 @@ @@ -6,6 +16,29 @@
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 {
margin-top: 0;
}
@ -17,11 +50,11 @@ @@ -17,11 +50,11 @@
font-weight: lighter;
&.connected {
color: #13d213;
color: $connected-color;
}
&.disconnected {
color: #d20000;
color: $disconnected-color;
}
}
}
@ -45,6 +78,24 @@ @@ -45,6 +78,24 @@
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 {
display: inline-block;
&, .ui.disabled.button {
pointer-events: auto !important;
}
}

2
client/styles/DurationView.scss

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

6
client/styles/ProgramSequenceView.scss

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

7
client/tsconfig.json

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

28
client/webpack.config.js

@ -2,9 +2,9 @@ const path = require("path"); @@ -2,9 +2,9 @@ const path = require("path");
const webpack = require("webpack");
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 UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const DashboardPlugin = require("webpack-dashboard/plugin");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
@ -170,21 +170,17 @@ function getConfig(env) { @@ -170,21 +170,17 @@ function getConfig(env) {
}
: undefined
}),
new FaviconsWebpackPlugin({
logo: path.resolve(paths.clientDir, "images", "favicon-96x96.png"),
emitStatis: false,
prefix: "static/icons-[hash]/"
}),
// new FaviconsWebpackPlugin({
// logo: path.resolve(paths.clientDir, "images", "favicon-96x96.png"),
// emitStatis: false,
// prefix: "static/icons-[hash]/"
// }),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === "production") { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(environ.stringified),
new CaseSensitivePathsPlugin(),
isProd &&
new UglifyJsPlugin({
sourceMap: shouldUseSourceMap
}),
isDev && new webpack.HotModuleReplacementPlugin(),
new ForkTsCheckerWebpackPlugin({
checkSyntacticErrors: true,
@ -239,13 +235,17 @@ function getConfig(env) { @@ -239,13 +235,17 @@ 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 },
plugins: plugins,
optimization: {
namedModules: isProd
namedModules: isProd,
minimizer: isProd ? [new TerserPlugin({
sourceMap: shouldUseSourceMap
})] : [],
},
devServer: {
hot: true,
@ -258,7 +258,7 @@ function getConfig(env) { @@ -258,7 +258,7 @@ function getConfig(env) {
target: paths.publicUrl
}
]
}
},
};
}

4
common/ErrorCode.ts

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

2
common/TypedEventEmitter.ts

@ -72,7 +72,7 @@ export function typedEventEmitter< @@ -72,7 +72,7 @@ export function typedEventEmitter<
const NewClass = class extends Base {
constructor(...args: any[]) {
super(...args);
EventEmitter.call(this);
EventEmitter.call(this as any);
}
};
Object.getOwnPropertyNames(EventEmitter.prototype).forEach(name => {

2
common/browserLogger.ts

@ -79,7 +79,7 @@ export function write(value: any) { @@ -79,7 +79,7 @@ export function write(value: any) {
} else {
fn = console.log;
}
fn.apply(null, args);
fn.apply(null, args as any);
}
function filter(value: any) {

2
common/jsonRpc/index.ts

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

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

2
common/sprinklersRpc/mqtt/index.ts

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

35
common/sprinklersRpc/schema/common.ts

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

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

38
common/sprinklersRpc/schema/list.ts

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

4
common/sprinklersRpc/schema/requests.ts

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

5
common/tsconfig.json

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

8
docker-compose.dev.yml

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

2
docker-compose.yml

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

80
package.json

@ -46,68 +46,72 @@ @@ -46,68 +46,72 @@
"@oclif/command": "^1.5.0",
"@oclif/config": "^1.7.4",
"@oclif/plugin-help": "^2.1.1",
"@types/split2": "^2.1.6",
"bcrypt": "^3.0.0",
"body-parser": "^1.18.3",
"chalk": "^2.4.1",
"cli-ux": "^4.8.1",
"cli-ux": "^5.3.1",
"express": "^4.16.3",
"express-pino-logger": "^4.0.0",
"express-promise-router": "^3.0.3",
"globby": "^10.0.1",
"jsonwebtoken": "^8.3.0",
"lodash": "^4.17.10",
"mobx": "^5.1.0",
"mobx-utils": "^5.0.1",
"module-alias": "^2.1.0",
"moment": "^2.22.2",
"mqtt": "^2.18.8",
"mqtt": "^3.0.0",
"pg": "^7.4.3",
"pino": "^5.4.0",
"pino-http": "^4.2.0",
"pump": "^3.0.0",
"reflect-metadata": "^0.1.12",
"serializr": "^1.3.0",
"split2": "^3.0.0",
"through2": "^2.0.3",
"terser-webpack-plugin": "^1.3.0",
"through2": "^3.0.1",
"typeorm": "^0.2.7",
"ws": "^6.0.0"
"ws": "^7.1.1"
},
"devDependencies": {
"@types/async": "^2.0.49",
"@types/bcrypt": "^2.0.0",
"@hot-loader/react-dom": "^16.8.6",
"@types/async": "^3.0.0",
"@types/bcrypt": "^3.0.0",
"@types/classnames": "^2.2.6",
"@types/core-js": "^2.5.0",
"@types/express": "^4.16.0",
"@types/jsonwebtoken": "^7.2.8",
"@types/jsonwebtoken": "^8.3.2",
"@types/lodash": "^4.14.116",
"@types/module-alias": "^2.0.0",
"@types/node": "^10.9.4",
"@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",
"@types/react": "16.4.13",
"@types/react-dom": "16.0.7",
"@types/react": "^16.7.13",
"@types/react-dom": "^16.0.11",
"@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",
"async": "^2.6.1",
"async": "^3.1.0",
"autoprefixer": "^9.1.3",
"cache-loader": "^1.2.2",
"cache-loader": "^4.1.0",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
"classnames": "^2.2.6",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"css-loader": "^3.1.0",
"dotenv": "^8.0.0",
"favicons-webpack-plugin": "^0.0.9",
"file-loader": "^2.0.0",
"file-loader": "^4.1.0",
"font-awesome": "^4.7.0",
"fork-ts-checker-webpack-plugin": "^0.4.9",
"fork-ts-checker-webpack-plugin": "^1.4.3",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.4.2",
"mobx-react": "^5.2.5",
"mini-css-extract-plugin": "^0.8.0",
"mobx-react": "^6.1.1",
"mobx-react-devtools": "^6.0.3",
"mobx-react-router": "^4.0.4",
"node-sass": "^4.9.3",
@ -116,42 +120,34 @@ @@ -116,42 +120,34 @@
"object-assign": "^4.1.1",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^5.3.0",
"postcss-preset-env": "^6.7.0",
"promise": "^8.0.1",
"prop-types": "^15.6.2",
"query-string": "^6.1.0",
"react": "16.4.2",
"react-dev-utils": "^5.0.2",
"react-dom": "16.4.2",
"react": "^16.8.0",
"react-dev-utils": "^9.0.1",
"react-dom": "^16.6.3",
"react-hot-loader": "^4.3.5",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-sortable-hoc": "^0.8.3",
"react-router": "^5.0.1",
"react-router-dom": "^5.0.1",
"react-sortable-hoc": "^1.9.1",
"sass-loader": "^7.1.0",
"semantic-ui-css": "^2.3.3",
"semantic-ui-react": "^0.82.3",
"semantic-ui-react": "^0.87.3",
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.0",
"thread-loader": "^1.2.0",
"ts-loader": "^4.5.0",
"thread-loader": "^2.1.2",
"ts-loader": "^6.0.4",
"tslint": "^5.11.0",
"tslint-config-prettier": "^1.15.0",
"tslint-consistent-codestyle": "^1.13.3",
"tslint-react": "^3.6.0",
"tslint-react": "^4.0.0",
"typescript": "^3.0.3",
"uglify-es": "^3.3.9",
"uglifyjs-webpack-plugin": "^1.3.0",
"url-loader": "^1.1.1",
"url-loader": "^2.1.0",
"webpack": "^4.17.1",
"webpack-bundle-analyzer": "^2.13.1",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.1.0",
"webpack-dashboard": "^2.0.0",
"webpack-dashboard": "^3.0.7",
"webpack-dev-server": "^3.1.7"
},
"resolutions": {
"**/@types/react": "16.4.11",
"**/@types/react-dom": "16.0.7",
"**/react": "16.4.2",
"**/react-dom": "16.4.2"
}
}

23
server/Database.ts

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

21
server/ManageCommand.ts

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
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

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
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) { @@ -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,

161
server/commands/device.ts

@ -0,0 +1,161 @@ @@ -0,0 +1,161 @@
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

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
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

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

154
server/commands/user.ts

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

6
server/entities/User.ts

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

45
server/express/api/devices.ts

@ -1,10 +1,13 @@ @@ -1,10 +1,13 @@
import { Request } from "express";
import PromiseRouter from "express-promise-router";
import { serialize } from "serializr";
import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode";
import * as schema from "@common/sprinklersRpc/schema";
import { DeviceToken } from "@common/TokenClaims";
import { generateDeviceToken } from "@server/authentication";
import { SprinklersDevice } from "@server/entities";
import { verifyAuthorization } from "@server/express/verifyAuthorization";
import { ServerState } from "@server/state";
@ -30,7 +33,7 @@ function randomDeviceId(): string { @@ -30,7 +33,7 @@ function randomDeviceId(): string {
export function devices(state: ServerState) {
const router = PromiseRouter();
router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
async function verifyUserDevice(req: Request): Promise<SprinklersDevice> {
const token = req.token!;
const userId = token.aud;
const deviceId = req.params.deviceId;
@ -44,12 +47,39 @@ export function devices(state: ServerState) { @@ -44,12 +47,39 @@ export function devices(state: ServerState) {
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 j = serialize(schema.sprinklersDevice, device);
res.send(j);
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(
"/register",
verifyAuthorization({
@ -76,8 +106,19 @@ export function devices(state: ServerState) { @@ -76,8 +106,19 @@ export function devices(state: ServerState) {
type: "device"
}),
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({
url: state.mqttUrl
mqttUrl: state.mqttUrl,
deviceId,
clientId,
});
}
);

11
server/express/requestLogger.ts

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

1
server/index.ts

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

16
server/repositories/SprinklersDeviceRepository.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { EntityRepository, Repository } from "typeorm";
import { DeepPartial, EntityRepository, Repository, SaveOptions } from "typeorm";
import { SprinklersDevice, User } from "@server/entities";
import UniqueConstraintError from "@server/UniqueConstraintError";
@EntityRepository(SprinklersDevice)
export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
@ -39,4 +40,17 @@ export class SprinklersDeviceRepository extends Repository<SprinklersDevice> { @@ -39,4 +40,17 @@ export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
}
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,33 +1,49 @@ @@ -1,33 +1,49 @@
import { EntityRepository, FindOneOptions, Repository } from "typeorm";
import { EntityRepository, FindConditions, FindOneOptions, Repository } from "typeorm";
import { User } from "@server/entities";
export interface FindUserOptions {
export interface FindUserOptions extends FindOneOptions<User> {
devices: boolean;
}
function applyDefaultOptions(
function computeOptions(
options?: Partial<FindUserOptions>
): FindOneOptions<User> {
const opts: FindUserOptions = { devices: false, ...options };
const relations = [opts.devices && "devices"].filter(Boolean) as string[];
return { relations };
const { devices, ...rest } = opts;
const relations = [devices && "devices"].filter(Boolean) as string[];
return { relations, ...rest };
}
@EntityRepository(User)
export class UserRepository extends Repository<User> {
findAll(options?: Partial<FindUserOptions>) {
const opts = applyDefaultOptions(options);
const opts = computeOptions(options);
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>) {
const opts = applyDefaultOptions(options);
return super.findOne(id, opts);
return this.findOneUser({ id }, options);
}
findByUsername(username: string, options?: Partial<FindUserOptions>) {
const opts = applyDefaultOptions(options);
return this.findOne({ username }, opts);
return this.findOneUser({ username }, options);
}
// 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,13 +23,20 @@ export class ServerState { @@ -23,13 +23,20 @@ export class ServerState {
async startDatabase() {
await this.database.connect();
logger.info("connected to database");
if (process.env.INSERT_TEST_DATA) {
await this.database.insertTestData();
logger.info("inserted test data");
try {
await this.database.insertTestData();
logger.info("inserted test data");
} catch (e) {
logger.error(e, "error inserting test data");
}
}
}
async stopDatabase() {
await this.database.disconnect();
}
async startMqtt() {
this.mqttClient.username = SUPERUSER;

3
server/tsconfig.json

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

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

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

18
tsconfig.json

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

6447
yarn.lock

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