mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-04-23 12:05:45 -04:00
first draft of react-based list ui (#111)
This commit is contained in:
parent
2677184c58
commit
08c3246982
4
.github/workflows/check-license.py
vendored
4
.github/workflows/check-license.py
vendored
@ -59,8 +59,8 @@ def main(args):
|
|||||||
missing = [f for f in args
|
missing = [f for f in args
|
||||||
if FILENAME_MATCHER.match(f) and not file_has_license(f)]
|
if FILENAME_MATCHER.match(f) and not file_has_license(f)]
|
||||||
if missing:
|
if missing:
|
||||||
print('The following files are missing expected copyright/license headers:')
|
print('The following files are missing expected copyright/license headers:', file=sys.stderr)
|
||||||
print('\n'.join(missing))
|
print('\n'.join(missing), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
129
ui/package-lock.json
generated
129
ui/package-lock.json
generated
@ -1879,17 +1879,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/core": {
|
"@material-ui/core": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-TtU3m3qw0FKtrDHCPmakJ25/20bGPRkLn8qjDkyCUYfg7kG4RLIwgiI0fKnF5PCDu46qL+s233+nVwAh8Iq+qw==",
|
"integrity": "sha512-etGxHyZn8REebM8SCcRl0ZaJmm3G6+I7oZqvuZf3Gg+SB03To8SgOkc0BXqteNjt+Z4Etz+xv1iy4RU+rFzGFA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@material-ui/styled-engine": "5.0.0-alpha.25",
|
"@material-ui/styled-engine": "5.0.0-alpha.25",
|
||||||
"@material-ui/styles": "5.0.0-alpha.25",
|
"@material-ui/styles": "5.0.0-alpha.26",
|
||||||
"@material-ui/system": "5.0.0-alpha.25",
|
"@material-ui/system": "5.0.0-alpha.26",
|
||||||
"@material-ui/types": "5.1.7",
|
"@material-ui/types": "5.1.7",
|
||||||
"@material-ui/unstyled": "5.0.0-alpha.25",
|
"@material-ui/unstyled": "5.0.0-alpha.26",
|
||||||
"@material-ui/utils": "5.0.0-alpha.25",
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
"@popperjs/core": "^2.4.4",
|
"@popperjs/core": "^2.4.4",
|
||||||
"@types/react-transition-group": "^4.2.0",
|
"@types/react-transition-group": "^4.2.0",
|
||||||
"clsx": "^1.0.4",
|
"clsx": "^1.0.4",
|
||||||
@ -1898,28 +1898,53 @@
|
|||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-is": "^16.8.0 || ^17.0.0",
|
"react-is": "^16.8.0 || ^17.0.0",
|
||||||
"react-transition-group": "^4.4.0"
|
"react-transition-group": "^4.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/system": {
|
||||||
|
"version": "5.0.0-alpha.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.26.tgz",
|
||||||
|
"integrity": "sha512-IiuXiNe1f/CFE4ybNGPu15rB3u3CuT6FKzMnBIQA4mezDOyXw+6vHm7FiBb3uk2LQMvTCJo6zael/QpdHHdwOA==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.4.4",
|
||||||
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
|
"csstype": "^3.0.2",
|
||||||
|
"prop-types": "^15.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@material-ui/utils": {
|
||||||
|
"version": "5.0.0-alpha.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz",
|
||||||
|
"integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.4.4",
|
||||||
|
"@types/prop-types": "^15.7.3",
|
||||||
|
"@types/react-is": "^16.7.1 || ^17.0.0",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/icons": {
|
"@material-ui/icons": {
|
||||||
"version": "5.0.0-alpha.24",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.24.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-b9WnCARLzy3tRmSShn5iV6EYpWNLmrv9P+1ebAGxkHYvOi7ZHsk6NeL61FNx+sJ9nSrLnk+N7rTYI0h03XViPA==",
|
"integrity": "sha512-omrMgRa90Uip1TW5KRvAoTHvdNkVgnVp8z5rF3ez1q8rgI+RgqLxyHE8ezpbN8LvWvrGU97OXDSDbcsrRUjUxw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4"
|
"@babel/runtime": "^7.4.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/lab": {
|
"@material-ui/lab": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-J5hYwWvVdJP8XRXz8JJs3Imn1xBQqQCCLnbxF59yHgdeGLsnw3RfJulnsxMSlSwk321hDnBlt4XmNf+scvLT0g==",
|
"integrity": "sha512-WqdLxN6v/gBOVKOwjrs+L9spmPQHRI+MMMtYoaHNY2t870OGuR8/jBxKaZVyq8G60bKHiGq8cg5ppHkQibQmyQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@date-io/date-fns": "^2.10.6",
|
"@date-io/date-fns": "^2.10.6",
|
||||||
"@date-io/dayjs": "^2.10.6",
|
"@date-io/dayjs": "^2.10.6",
|
||||||
"@date-io/luxon": "^2.10.6",
|
"@date-io/luxon": "^2.10.6",
|
||||||
"@date-io/moment": "^2.10.6",
|
"@date-io/moment": "^2.10.6",
|
||||||
"@material-ui/system": "5.0.0-alpha.25",
|
"@material-ui/system": "5.0.0-alpha.26",
|
||||||
"@material-ui/utils": "5.0.0-alpha.25",
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
"clsx": "^1.0.4",
|
"clsx": "^1.0.4",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-is": "^16.8.0 || ^17.0.0",
|
"react-is": "^16.8.0 || ^17.0.0",
|
||||||
@ -1938,14 +1963,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/styles": {
|
"@material-ui/styles": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-VODyi/JT0njmkcyRKiYPacKQmhS11bidg6AMTgAU5KjdmchFPRIl5lwIHFYeRtVEsHX9/9tE0pPlCJIhVYAYsw==",
|
"integrity": "sha512-HL1NdzIruDkRMoPhb6qRDGYO4Bn3xM/VwE3pfZPmKyjOz7tCvtqHR5uzdI34EcodgfM/Hm/HSDB69CBTpxBt4A==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@emotion/hash": "^0.8.0",
|
"@emotion/hash": "^0.8.0",
|
||||||
"@material-ui/types": "5.1.7",
|
"@material-ui/types": "5.1.7",
|
||||||
"@material-ui/utils": "5.0.0-alpha.25",
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
"clsx": "^1.0.4",
|
"clsx": "^1.0.4",
|
||||||
"csstype": "^3.0.2",
|
"csstype": "^3.0.2",
|
||||||
"hoist-non-react-statics": "^3.3.2",
|
"hoist-non-react-statics": "^3.3.2",
|
||||||
@ -1958,15 +1983,29 @@
|
|||||||
"jss-plugin-rule-value-function": "^10.0.3",
|
"jss-plugin-rule-value-function": "^10.0.3",
|
||||||
"jss-plugin-vendor-prefixer": "^10.0.3",
|
"jss-plugin-vendor-prefixer": "^10.0.3",
|
||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/utils": {
|
||||||
|
"version": "5.0.0-alpha.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz",
|
||||||
|
"integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.4.4",
|
||||||
|
"@types/prop-types": "^15.7.3",
|
||||||
|
"@types/react-is": "^16.7.1 || ^17.0.0",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/system": {
|
"@material-ui/system": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-4TrsBfB9MMsUj8o1jGRBI4Zv1SSASDwg1lfad5SYtlGhL+0LiSDO/XhVvOvYaRi749lguux2rlrVZpIut0jgMA==",
|
"integrity": "sha512-IiuXiNe1f/CFE4ybNGPu15rB3u3CuT6FKzMnBIQA4mezDOyXw+6vHm7FiBb3uk2LQMvTCJo6zael/QpdHHdwOA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@material-ui/utils": "5.0.0-alpha.25",
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
"csstype": "^3.0.2",
|
"csstype": "^3.0.2",
|
||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
}
|
}
|
||||||
@ -1977,21 +2016,35 @@
|
|||||||
"integrity": "sha512-OSpB0gEKZm5h4izTLyipb34PkfazpvusgQMDTmFkSuqcKoChTshfGejEYX6uaZ+4m5xlT5qzihE6eKA+JnjELg=="
|
"integrity": "sha512-OSpB0gEKZm5h4izTLyipb34PkfazpvusgQMDTmFkSuqcKoChTshfGejEYX6uaZ+4m5xlT5qzihE6eKA+JnjELg=="
|
||||||
},
|
},
|
||||||
"@material-ui/unstyled": {
|
"@material-ui/unstyled": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-LqAXDhSk2GC3C3SX0U5V3U0WA6gAS5Ayrozo9Br2XtxfEDdPTDmR1R7csT+DruyP3vGBs4k4ILIUz8KfC/kjww==",
|
"integrity": "sha512-6xoakfiqtT1/KOcN3QPc5Avf8llNn2eklpKiEY4Jut6wIw5DHlGYi4jlMe6e7SvxUXTVdz0S55zR3GCLlKfcPg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@material-ui/utils": "5.0.0-alpha.25",
|
"@material-ui/utils": "5.0.0-alpha.26",
|
||||||
"clsx": "^1.0.4",
|
"clsx": "^1.0.4",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-is": "^16.8.0 || ^17.0.0"
|
"react-is": "^16.8.0 || ^17.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/utils": {
|
||||||
|
"version": "5.0.0-alpha.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz",
|
||||||
|
"integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.4.4",
|
||||||
|
"@types/prop-types": "^15.7.3",
|
||||||
|
"@types/react-is": "^16.7.1 || ^17.0.0",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/utils": {
|
"@material-ui/utils": {
|
||||||
"version": "5.0.0-alpha.25",
|
"version": "5.0.0-alpha.26",
|
||||||
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.25.tgz",
|
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.26.tgz",
|
||||||
"integrity": "sha512-wgW1ua+ncAU6lrE2bFhd9iU1xmpxj1KL+YRJa0PoFpFgLNVpSTCVHynMlRAERNP+CteazFHj5upkigqJpV406Q==",
|
"integrity": "sha512-h2SAkucZU+SlC14b3Vo3YzTD9dJQKFPVET6RvRhK/9Ommf+V4dPc6otaTMu5ES696BDqRfY7G2aeaTtkrd2bdg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.4.4",
|
"@babel/runtime": "^7.4.4",
|
||||||
"@types/prop-types": "^15.7.3",
|
"@types/prop-types": "^15.7.3",
|
||||||
@ -2066,9 +2119,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@popperjs/core": {
|
"@popperjs/core": {
|
||||||
"version": "2.8.3",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.0.tgz",
|
||||||
"integrity": "sha512-olsVs3lo8qKycPoWAUv4bMyoTGVXsEpLR9XxGk3LJR5Qa92a1Eg/Fj1ATdhwtC/6VMaKtsz1nSAeheD2B2Ru9A=="
|
"integrity": "sha512-wjtKehFAIARq2OxK8j3JrggNlEslJfNuSm2ArteIbKyRMts2g0a7KzTxfRVNUM+O0gnBJ2hNV8nWPOYBgI1sew=="
|
||||||
},
|
},
|
||||||
"@rollup/plugin-node-resolve": {
|
"@rollup/plugin-node-resolve": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
@ -5181,6 +5234,16 @@
|
|||||||
"whatwg-url": "^8.0.0"
|
"whatwg-url": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"version": "2.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.18.0.tgz",
|
||||||
|
"integrity": "sha512-NYyAg4wRmGVU4miKq5ivRACOODdZRY3q5WLmOJSq8djyzftYphU7dTHLcEtLqEvfqMKQ0jVv91P4BAwIjsXIcw=="
|
||||||
|
},
|
||||||
|
"date-fns-tz": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-mD26WkejWz842RggjFrKsY6ehGgyBQSJ209mn83/vsjhgQ5WbdVvBzJ0CuosnGdklDxOvOppQ/wn1UgvTOPKPw=="
|
||||||
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
||||||
|
@ -5,14 +5,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.1.5",
|
"@emotion/react": "^11.1.5",
|
||||||
"@emotion/styled": "^11.1.5",
|
"@emotion/styled": "^11.1.5",
|
||||||
"@material-ui/core": "^5.0.0-alpha.25",
|
"@fontsource/roboto": "^4.2.1",
|
||||||
"@material-ui/icons": "^5.0.0-alpha.24",
|
"@material-ui/core": "^5.0.0-alpha.26",
|
||||||
"@material-ui/lab": "^5.0.0-alpha.25",
|
"@material-ui/icons": "^5.0.0-alpha.26",
|
||||||
|
"@material-ui/lab": "^5.0.0-alpha.26",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/node": "^14.14.22",
|
"@types/node": "^14.14.22",
|
||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
"@fontsource/roboto": "^4.2.1",
|
"date-fns": "^2.18.0",
|
||||||
|
"date-fns-tz": "^1.1.3",
|
||||||
"gzipper": "^4.4.0",
|
"gzipper": "^4.4.0",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
|
@ -8,7 +8,9 @@ import * as api from "./api";
|
|||||||
import MoonfireMenu from "./AppMenu";
|
import MoonfireMenu from "./AppMenu";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import { useSnackbars } from "./snackbars";
|
import { useSnackbars } from "./snackbars";
|
||||||
import { Session } from "./types";
|
import { Camera, Session } from "./types";
|
||||||
|
import List from "./List";
|
||||||
|
import AppBar from "@material-ui/core/AppBar";
|
||||||
|
|
||||||
type LoginState =
|
type LoginState =
|
||||||
| "logged-in"
|
| "logged-in"
|
||||||
@ -18,6 +20,8 @@ type LoginState =
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [cameras, setCameras] = useState<Camera[] | null>(null);
|
||||||
|
const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
|
||||||
const [fetchSeq, setFetchSeq] = useState(0);
|
const [fetchSeq, setFetchSeq] = useState(0);
|
||||||
const [loginState, setLoginState] = useState<LoginState>("not-logged-in");
|
const [loginState, setLoginState] = useState<LoginState>("not-logged-in");
|
||||||
const [error, setError] = useState<api.FetchError | null>(null);
|
const [error, setError] = useState<api.FetchError | null>(null);
|
||||||
@ -71,6 +75,8 @@ function App() {
|
|||||||
resp.response.session === undefined ? "not-logged-in" : "logged-in"
|
resp.response.session === undefined ? "not-logged-in" : "logged-in"
|
||||||
);
|
);
|
||||||
setSession(resp.response.session || null);
|
setSession(resp.response.session || null);
|
||||||
|
setCameras(resp.response.cameras);
|
||||||
|
setTimeZoneName(resp.response.timeZoneName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.debug("Toplevel fetch num", fetchSeq);
|
console.debug("Toplevel fetch num", fetchSeq);
|
||||||
@ -80,9 +86,9 @@ function App() {
|
|||||||
abort.abort();
|
abort.abort();
|
||||||
};
|
};
|
||||||
}, [fetchSeq]);
|
}, [fetchSeq]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<AppBar position="static">
|
||||||
<MoonfireMenu
|
<MoonfireMenu
|
||||||
session={session}
|
session={session}
|
||||||
setSession={setSession}
|
setSession={setSession}
|
||||||
@ -91,6 +97,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
logout={logout}
|
logout={logout}
|
||||||
/>
|
/>
|
||||||
|
</AppBar>
|
||||||
<Login
|
<Login
|
||||||
onSuccess={onLoginSuccess}
|
onSuccess={onLoginSuccess}
|
||||||
open={
|
open={
|
||||||
@ -113,6 +120,9 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
{cameras != null && cameras.length > 0 && (
|
||||||
|
<List cameras={cameras} timeZoneName={timeZoneName!} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
import AppBar from "@material-ui/core/AppBar";
|
|
||||||
import Button from "@material-ui/core/Button";
|
import Button from "@material-ui/core/Button";
|
||||||
import IconButton from "@material-ui/core/IconButton";
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
import Menu from "@material-ui/core/Menu";
|
import Menu from "@material-ui/core/Menu";
|
||||||
@ -28,6 +27,7 @@ interface Props {
|
|||||||
setSession: (session: Session | null) => void;
|
setSession: (session: Session | null) => void;
|
||||||
requestLogin: () => void;
|
requestLogin: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
menuClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://material-ui.com/components/app-bar/
|
// https://material-ui.com/components/app-bar/
|
||||||
@ -56,9 +56,14 @@ function MoonfireMenu(props: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="static">
|
<>
|
||||||
<Toolbar variant="dense">
|
<Toolbar variant="dense">
|
||||||
<IconButton edge="start" color="inherit" aria-label="menu">
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
|
onClick={props.menuClick}
|
||||||
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6" className={classes.title}>
|
<Typography variant="h6" className={classes.title}>
|
||||||
@ -101,7 +106,7 @@ function MoonfireMenu(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
130
ui/src/List/StreamMultiSelector.tsx
Normal file
130
ui/src/List/StreamMultiSelector.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
import Card from "@material-ui/core/Card";
|
||||||
|
import { Camera, Stream, StreamType } from "../types";
|
||||||
|
import Checkbox from "@material-ui/core/Checkbox";
|
||||||
|
import { makeStyles, useTheme } from "@material-ui/core/styles";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cameras: Camera[];
|
||||||
|
selected: Set<Stream>;
|
||||||
|
setSelected: (selected: Set<Stream>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
streamSelectorTable: {
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
"& td:first-child": {
|
||||||
|
paddingRight: "3px",
|
||||||
|
},
|
||||||
|
"& td:not(:first-child)": {
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
check: {
|
||||||
|
padding: "3px",
|
||||||
|
},
|
||||||
|
"@media (pointer: fine)": {
|
||||||
|
check: {
|
||||||
|
padding: "0px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Returns a table which allows selecting zero or more streams. */
|
||||||
|
const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const classes = useStyles();
|
||||||
|
const setStream = (s: Stream, checked: boolean) => {
|
||||||
|
console.log("toggle", s.camera.shortName, s.streamType);
|
||||||
|
const updated = new Set(selected);
|
||||||
|
if (checked) {
|
||||||
|
updated.add(s);
|
||||||
|
} else {
|
||||||
|
updated.delete(s);
|
||||||
|
}
|
||||||
|
setSelected(updated);
|
||||||
|
};
|
||||||
|
const toggleType = (st: StreamType) => {
|
||||||
|
let updated = new Set(selected);
|
||||||
|
let foundAny = false;
|
||||||
|
for (const s of selected) {
|
||||||
|
if (s.streamType === st) {
|
||||||
|
updated.delete(s);
|
||||||
|
foundAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundAny) {
|
||||||
|
for (const c of cameras) {
|
||||||
|
if (c.streams[st] !== undefined) {
|
||||||
|
updated.add(c.streams[st]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelected(updated);
|
||||||
|
};
|
||||||
|
const toggleCamera = (c: Camera) => {
|
||||||
|
const updated = new Set(selected);
|
||||||
|
let foundAny = false;
|
||||||
|
for (const st in c.streams) {
|
||||||
|
const s = c.streams[st as StreamType];
|
||||||
|
if (selected.has(s)) {
|
||||||
|
updated.delete(s);
|
||||||
|
foundAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundAny) {
|
||||||
|
for (const st in c.streams) {
|
||||||
|
updated.add(c.streams[st as StreamType]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelected(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraRows = cameras.map((c) => {
|
||||||
|
function checkbox(st: StreamType) {
|
||||||
|
const s = c.streams[st];
|
||||||
|
if (s === undefined) {
|
||||||
|
return <Checkbox className={classes.check} disabled />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
className={classes.check}
|
||||||
|
size="small"
|
||||||
|
checked={selected.has(s)}
|
||||||
|
onChange={(_, checked: boolean) => setStream(s, checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={c.uuid}>
|
||||||
|
<td onClick={() => toggleCamera(c)}>{c.shortName}</td>
|
||||||
|
<td>{checkbox("main")}</td>
|
||||||
|
<td>{checkbox("sub")}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className={classes.streamSelectorTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td />
|
||||||
|
<td onClick={() => toggleType("main")}>main</td>
|
||||||
|
<td onClick={() => toggleType("sub")}>sub</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{cameraRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StreamMultiSelector;
|
339
ui/src/List/TimerangeSelector.tsx
Normal file
339
ui/src/List/TimerangeSelector.tsx
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
import { Stream } from "../types";
|
||||||
|
import StaticDatePicker from "@material-ui/lab/StaticDatePicker";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { zonedTimeToUtc } from "date-fns-tz";
|
||||||
|
import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns";
|
||||||
|
import startOfDay from "date-fns/startOfDay";
|
||||||
|
import Card from "@material-ui/core/Card";
|
||||||
|
import { useTheme } from "@material-ui/core/styles";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import FormLabel from "@material-ui/core/FormLabel";
|
||||||
|
import Radio from "@material-ui/core/Radio";
|
||||||
|
import RadioGroup from "@material-ui/core/RadioGroup";
|
||||||
|
import TimePicker, { TimePickerProps } from "@material-ui/lab/TimePicker";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedStreams: Set<Stream>;
|
||||||
|
timeZoneName: string;
|
||||||
|
range90k: [number, number] | null;
|
||||||
|
setRange90k: (range: [number, number] | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MyTimePicker = (
|
||||||
|
props: Pick<TimePickerProps, "value" | "onChange" | "disabled">
|
||||||
|
) => (
|
||||||
|
<TimePicker
|
||||||
|
views={["hours", "minutes", "seconds"]}
|
||||||
|
renderInput={(params) => <TextField size="small" {...params} />}
|
||||||
|
inputFormat="HH:mm:ss"
|
||||||
|
mask="__:__:__"
|
||||||
|
ampm={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines the date-part of <tt>dayMillis</tt> and the time part of
|
||||||
|
* <tt>time</tt>. If <tt>time</tt> is null, assume it reaches the end of the
|
||||||
|
* day.
|
||||||
|
*/
|
||||||
|
const combine = (dayMillis: number, time: Date | null) => {
|
||||||
|
const start = new Date(dayMillis);
|
||||||
|
if (time === null) {
|
||||||
|
return addDays(start, 1);
|
||||||
|
}
|
||||||
|
return addMilliseconds(
|
||||||
|
start,
|
||||||
|
differenceInMilliseconds(time, startOfDay(time))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed days to select (ones with video).
|
||||||
|
*
|
||||||
|
* These are stored in a funny format: number of milliseconds since epoch of
|
||||||
|
* the start of the given day in the browser's time zone. This is because
|
||||||
|
* (a) Date objects are always in the local time zone and date-fn rolls with
|
||||||
|
* that, and (b) Date objects don't work well in a set. Javascript's
|
||||||
|
* "same-value-zero algorithm" means that two different Date objects never
|
||||||
|
* compare the same.
|
||||||
|
*/
|
||||||
|
type AllowedDays = {
|
||||||
|
minMillis: number;
|
||||||
|
maxMillis: number;
|
||||||
|
allMillis: Set<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EndDayType = "same-day" | "other-day";
|
||||||
|
|
||||||
|
type DaysState = {
|
||||||
|
allowed: AllowedDays | null;
|
||||||
|
|
||||||
|
/** [start, end] in same format as described for <tt>AllowedDays</tt>. */
|
||||||
|
rangeMillis: [number, number] | null;
|
||||||
|
endType: EndDayType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaysOpUpdateSelectedStreams = {
|
||||||
|
op: "update-selected-streams";
|
||||||
|
selectedStreams: Set<Stream>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaysOpSetStartDay = {
|
||||||
|
op: "set-start-day";
|
||||||
|
newStartDate: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaysOpSetEndDay = {
|
||||||
|
op: "set-end-day";
|
||||||
|
newEndDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaysOpSetEndDayType = {
|
||||||
|
op: "set-end-type";
|
||||||
|
newEndType: EndDayType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaysOp =
|
||||||
|
| DaysOpUpdateSelectedStreams
|
||||||
|
| DaysOpSetStartDay
|
||||||
|
| DaysOpSetEndDay
|
||||||
|
| DaysOpSetEndDayType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes an <tt>AllowedDays</tt> from the given streams.
|
||||||
|
* Returns null if there are no allowed days.
|
||||||
|
*/
|
||||||
|
function computeAllowedDayInfo(
|
||||||
|
selectedStreams: Set<Stream>
|
||||||
|
): AllowedDays | null {
|
||||||
|
let minMillis = null;
|
||||||
|
let maxMillis = null;
|
||||||
|
let allMillis = new Set<number>();
|
||||||
|
for (const s of selectedStreams) {
|
||||||
|
for (const d in s.days) {
|
||||||
|
const t = new Date(d + "T00:00:00").getTime();
|
||||||
|
if (minMillis === null || t < minMillis) {
|
||||||
|
minMillis = t;
|
||||||
|
}
|
||||||
|
if (maxMillis === null || t > maxMillis) {
|
||||||
|
maxMillis = t;
|
||||||
|
}
|
||||||
|
allMillis.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minMillis === null || maxMillis === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minMillis,
|
||||||
|
maxMillis,
|
||||||
|
allMillis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMillis = (d: Date) => startOfDay(d).getTime();
|
||||||
|
|
||||||
|
function daysStateReducer(old: DaysState, op: DaysOp): DaysState {
|
||||||
|
let state = { ...old };
|
||||||
|
|
||||||
|
function updateStart(newStart: number) {
|
||||||
|
if (
|
||||||
|
state.rangeMillis === null ||
|
||||||
|
state.endType === "same-day" ||
|
||||||
|
state.rangeMillis[1] < newStart
|
||||||
|
) {
|
||||||
|
state.rangeMillis = [newStart, newStart];
|
||||||
|
} else {
|
||||||
|
state.rangeMillis[0] = newStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (op.op) {
|
||||||
|
case "update-selected-streams":
|
||||||
|
state.allowed = computeAllowedDayInfo(op.selectedStreams);
|
||||||
|
if (state.allowed === null) {
|
||||||
|
state.rangeMillis = null;
|
||||||
|
} else if (state.rangeMillis === null) {
|
||||||
|
state.rangeMillis = [state.allowed.maxMillis, state.allowed.maxMillis];
|
||||||
|
} else {
|
||||||
|
if (state.rangeMillis[0] < state.allowed.minMillis) {
|
||||||
|
updateStart(state.allowed.minMillis);
|
||||||
|
}
|
||||||
|
if (state.rangeMillis[1] > state.allowed.maxMillis) {
|
||||||
|
state.rangeMillis[1] = state.allowed.maxMillis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "set-start-day":
|
||||||
|
if (op.newStartDate === null) {
|
||||||
|
state.rangeMillis = null;
|
||||||
|
} else {
|
||||||
|
const millis = toMillis(op.newStartDate);
|
||||||
|
if (state.allowed === null || state.allowed.minMillis > millis) {
|
||||||
|
console.error("Invalid start day selection ", op.newStartDate);
|
||||||
|
} else {
|
||||||
|
updateStart(millis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "set-end-day":
|
||||||
|
const millis = toMillis(op.newEndDate);
|
||||||
|
if (
|
||||||
|
state.rangeMillis === null ||
|
||||||
|
state.allowed === null ||
|
||||||
|
state.allowed.maxMillis < millis
|
||||||
|
) {
|
||||||
|
console.error("Invalid end day selection ", op.newEndDate);
|
||||||
|
} else {
|
||||||
|
state.rangeMillis[1] = millis;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "set-end-type":
|
||||||
|
state.endType = op.newEndType;
|
||||||
|
if (state.endType === "same-day" && state.rangeMillis !== null) {
|
||||||
|
state.rangeMillis[1] = state.rangeMillis[0];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimerangeSelector = ({
|
||||||
|
selectedStreams,
|
||||||
|
timeZoneName,
|
||||||
|
range90k,
|
||||||
|
setRange90k,
|
||||||
|
}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [days, updateDays] = React.useReducer(daysStateReducer, {
|
||||||
|
allowed: null,
|
||||||
|
rangeMillis: null,
|
||||||
|
endType: "same-day",
|
||||||
|
});
|
||||||
|
const [startTime, setStartTime] = React.useState<any>(
|
||||||
|
new Date("1970-01-01T00:00:00")
|
||||||
|
);
|
||||||
|
const [endTime, setEndTime] = React.useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => updateDays({ op: "update-selected-streams", selectedStreams }),
|
||||||
|
[selectedStreams]
|
||||||
|
);
|
||||||
|
const shouldDisableDate = (date: Date | null) => {
|
||||||
|
return (
|
||||||
|
days.allowed === null ||
|
||||||
|
!days.allowed.allMillis.has(startOfDay(date!).getTime())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update range90k to reflect the selected options.
|
||||||
|
useEffect(() => {
|
||||||
|
if (days.rangeMillis === null) {
|
||||||
|
setRange90k(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = combine(days.rangeMillis[0], startTime);
|
||||||
|
const end = combine(days.rangeMillis[1], endTime);
|
||||||
|
setRange90k([
|
||||||
|
zonedTimeToUtc(start, timeZoneName).getTime() * 90,
|
||||||
|
zonedTimeToUtc(end, timeZoneName).getTime() * 90,
|
||||||
|
]);
|
||||||
|
}, [days, startTime, endTime, timeZoneName, setRange90k]);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
let startDate = null;
|
||||||
|
let endDate = null;
|
||||||
|
if (days.rangeMillis !== null) {
|
||||||
|
startDate = new Date(days.rangeMillis[0]);
|
||||||
|
endDate = new Date(days.rangeMillis[1]);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card sx={{ padding: theme.spacing(1) }}>
|
||||||
|
<div>
|
||||||
|
<FormLabel component="legend">From</FormLabel>
|
||||||
|
<StaticDatePicker
|
||||||
|
displayStaticWrapperAs="desktop"
|
||||||
|
value={startDate}
|
||||||
|
shouldDisableDate={shouldDisableDate}
|
||||||
|
maxDate={
|
||||||
|
days.allowed === null ? today : new Date(days.allowed.maxMillis)
|
||||||
|
}
|
||||||
|
minDate={
|
||||||
|
days.allowed === null ? today : new Date(days.allowed.minMillis)
|
||||||
|
}
|
||||||
|
onChange={(d: Date | null) => {
|
||||||
|
updateDays({ op: "set-start-day", newStartDate: d });
|
||||||
|
}}
|
||||||
|
renderInput={(params) => <TextField {...params} variant="outlined" />}
|
||||||
|
/>
|
||||||
|
<MyTimePicker
|
||||||
|
value={startTime}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
console.log("start time onChange", newValue);
|
||||||
|
if (newValue === null || isFinite((newValue as Date).getTime())) {
|
||||||
|
setStartTime(newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={days.allowed === null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel component="legend">To</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={days.endType}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateDays({
|
||||||
|
op: "set-end-type",
|
||||||
|
newEndType: e.target.value as EndDayType,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value="same-day"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label="Same day"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value="other-day"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label="Other day"
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
<StaticDatePicker
|
||||||
|
displayStaticWrapperAs="desktop"
|
||||||
|
value={endDate}
|
||||||
|
shouldDisableDate={(d: Date | null) =>
|
||||||
|
days.endType !== "other-day" || shouldDisableDate(d)
|
||||||
|
}
|
||||||
|
maxDate={
|
||||||
|
startDate === null ? today : new Date(days.allowed!.maxMillis)
|
||||||
|
}
|
||||||
|
minDate={startDate === null ? today : startDate}
|
||||||
|
onChange={(d: Date | null) => {
|
||||||
|
updateDays({ op: "set-end-day", newEndDate: d! });
|
||||||
|
}}
|
||||||
|
renderInput={(params) => <TextField {...params} variant="outlined" />}
|
||||||
|
/>
|
||||||
|
<MyTimePicker
|
||||||
|
value={endTime}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
if (newValue === null || isFinite((newValue as Date).getTime())) {
|
||||||
|
setEndTime(newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={days.allowed === null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimerangeSelector;
|
118
ui/src/List/VideoList.tsx
Normal file
118
ui/src/List/VideoList.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||||
|
import React from "react";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { useSnackbars } from "../snackbars";
|
||||||
|
import { Stream } from "../types";
|
||||||
|
import TableBody from "@material-ui/core/TableBody";
|
||||||
|
import TableCell from "@material-ui/core/TableCell";
|
||||||
|
import TableRow from "@material-ui/core/TableRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stream: Stream;
|
||||||
|
range90k: [number, number] | null;
|
||||||
|
setActiveRecording: (recording: [Stream, api.Recording] | null) => void;
|
||||||
|
formatTime: (time90k: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameRateFmt = new Intl.NumberFormat([], {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeFmt = new Intl.NumberFormat([], {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a <tt>TableBody</tt> with a list of videos for a given
|
||||||
|
* <tt>stream</tt> and <tt>range90k</tt>.
|
||||||
|
*
|
||||||
|
* The parent is responsible for creating the greater table.
|
||||||
|
*
|
||||||
|
* When one is clicked, calls <tt>setActiveRecording</tt>.
|
||||||
|
*/
|
||||||
|
const VideoList = ({
|
||||||
|
stream,
|
||||||
|
range90k,
|
||||||
|
setActiveRecording,
|
||||||
|
formatTime,
|
||||||
|
}: Props) => {
|
||||||
|
const snackbars = useSnackbars();
|
||||||
|
const [
|
||||||
|
response,
|
||||||
|
setResponse,
|
||||||
|
] = React.useState<api.FetchResult<api.RecordingsResponse> | null>(null);
|
||||||
|
const [showLoading, setShowLoading] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const doFetch = async (signal: AbortSignal, range90k: [number, number]) => {
|
||||||
|
const req: api.RecordingsRequest = {
|
||||||
|
cameraUuid: stream.camera.uuid,
|
||||||
|
stream: stream.streamType,
|
||||||
|
startTime90k: range90k[0],
|
||||||
|
endTime90k: range90k[1],
|
||||||
|
};
|
||||||
|
setResponse(await api.recordings(req, { signal }));
|
||||||
|
};
|
||||||
|
if (range90k != null) {
|
||||||
|
doFetch(abort.signal, range90k);
|
||||||
|
const timeout = setTimeout(() => setShowLoading(true), 1000);
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [range90k, snackbars, stream]);
|
||||||
|
|
||||||
|
let body = null;
|
||||||
|
if (response === null) {
|
||||||
|
if (showLoading) {
|
||||||
|
body = (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6}>
|
||||||
|
<CircularProgress />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (response.status === "error") {
|
||||||
|
body = (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6}>Error: {response.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
} else if (response.status === "success") {
|
||||||
|
const resp = response.response;
|
||||||
|
body = resp.recordings.map((r: api.Recording) => {
|
||||||
|
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
|
||||||
|
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={r.startId}
|
||||||
|
onClick={() => setActiveRecording([stream, r])}
|
||||||
|
>
|
||||||
|
<TableCell>{formatTime(r.startTime90k)}</TableCell>
|
||||||
|
<TableCell>{formatTime(r.endTime90k)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{vse.width}x{vse.height}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{frameRateFmt.format(r.videoSamples / durationSec)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sizeFmt.format(r.sampleFileBytes / 1048576)} MiB
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sizeFmt.format((r.sampleFileBytes / durationSec) * 0.000008)} Mbps
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return <TableBody>{body}</TableBody>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoList;
|
165
ui/src/List/index.tsx
Normal file
165
ui/src/List/index.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Camera, Stream } from "../types";
|
||||||
|
import * as api from "../api";
|
||||||
|
import VideoList from "./VideoList";
|
||||||
|
import { makeStyles, Theme } from "@material-ui/core/styles";
|
||||||
|
import Modal from "@material-ui/core/Modal";
|
||||||
|
import format from "date-fns/format";
|
||||||
|
import utcToZonedTime from "date-fns-tz/utcToZonedTime";
|
||||||
|
import Table from "@material-ui/core/Table";
|
||||||
|
import TableCell from "@material-ui/core/TableCell";
|
||||||
|
import TableContainer from "@material-ui/core/TableContainer";
|
||||||
|
import TableHead from "@material-ui/core/TableHead";
|
||||||
|
import TableRow from "@material-ui/core/TableRow";
|
||||||
|
import Paper from "@material-ui/core/Paper";
|
||||||
|
import StreamMultiSelector from "./StreamMultiSelector";
|
||||||
|
import TimerangeSelector from "./TimerangeSelector";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
root: {
|
||||||
|
display: "flex",
|
||||||
|
[theme.breakpoints.down("md")]: {
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
margin: theme.spacing(2),
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
[theme.breakpoints.up("md")]: {
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
|
[theme.breakpoints.down("md")]: {
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
objectFit: "contain",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "#000",
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
background: theme.palette.primary.light,
|
||||||
|
color: theme.palette.primary.contrastText,
|
||||||
|
},
|
||||||
|
videoTable: {
|
||||||
|
flexGrow: 1,
|
||||||
|
"& .MuiTableBody-root:not(:last-child):after": {
|
||||||
|
content: "''",
|
||||||
|
display: "block",
|
||||||
|
height: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timeZoneName: string;
|
||||||
|
|
||||||
|
cameras: Camera[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Main = ({ cameras, timeZoneName }: Props) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selected streams to display and use for selecting time ranges.
|
||||||
|
* This currently uses the <tt>Stream</tt> object, which means it will
|
||||||
|
* not be stable across top-level API fetches. Maybe an id would be better.
|
||||||
|
*/
|
||||||
|
const [selectedStreams, setSelectedStreams] = useState<Set<Stream>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Selected time range. */
|
||||||
|
const [range90k, setRange90k] = useState<[number, number] | null>(null);
|
||||||
|
|
||||||
|
const [activeRecording, setActiveRecording] = useState<
|
||||||
|
[Stream, api.Recording] | null
|
||||||
|
>(null);
|
||||||
|
const formatTime = useMemo(() => {
|
||||||
|
return (time90k: number) => {
|
||||||
|
return format(
|
||||||
|
utcToZonedTime(new Date(time90k / 90), timeZoneName),
|
||||||
|
"d MMM yyyy HH:mm:ss"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [timeZoneName]);
|
||||||
|
|
||||||
|
let videoLists = [];
|
||||||
|
for (const s of selectedStreams) {
|
||||||
|
videoLists.push(
|
||||||
|
<React.Fragment key={`${s.camera.uuid}-${s.streamType}`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className={classes.camera}>
|
||||||
|
{s.camera.shortName} {s.streamType}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>start</TableCell>
|
||||||
|
<TableCell>end</TableCell>
|
||||||
|
<TableCell>resolution</TableCell>
|
||||||
|
<TableCell>fps</TableCell>
|
||||||
|
<TableCell>storage</TableCell>
|
||||||
|
<TableCell>bitrate</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<VideoList
|
||||||
|
stream={s}
|
||||||
|
range90k={range90k}
|
||||||
|
setActiveRecording={setActiveRecording}
|
||||||
|
formatTime={formatTime}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const closeModal = (event: {}, reason: string) => {
|
||||||
|
console.log("closeModal", reason);
|
||||||
|
setActiveRecording(null);
|
||||||
|
};
|
||||||
|
const recordingsTable = (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small" className={classes.videoTable}>
|
||||||
|
{videoLists}
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div className={classes.selectors}>
|
||||||
|
<StreamMultiSelector
|
||||||
|
cameras={cameras}
|
||||||
|
selected={selectedStreams}
|
||||||
|
setSelected={setSelectedStreams}
|
||||||
|
/>
|
||||||
|
<TimerangeSelector
|
||||||
|
selectedStreams={selectedStreams}
|
||||||
|
range90k={range90k}
|
||||||
|
setRange90k={setRange90k}
|
||||||
|
timeZoneName={timeZoneName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{videoLists.length > 0 && recordingsTable}
|
||||||
|
{activeRecording != null && (
|
||||||
|
<Modal open onClose={closeModal}>
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
autoPlay
|
||||||
|
className={classes.video}
|
||||||
|
src={api.recordingUrl(
|
||||||
|
activeRecording[0].camera.uuid,
|
||||||
|
activeRecording[0].streamType,
|
||||||
|
activeRecording[1]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Main;
|
117
ui/src/api.ts
117
ui/src/api.ts
@ -13,12 +13,14 @@
|
|||||||
|
|
||||||
import { Camera, Session } from "./types";
|
import { Camera, Session } from "./types";
|
||||||
|
|
||||||
interface FetchSuccess<T> {
|
export type StreamType = "main" | "sub";
|
||||||
|
|
||||||
|
export interface FetchSuccess<T> {
|
||||||
status: "success";
|
status: "success";
|
||||||
response: T;
|
response: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchAborted {
|
export interface FetchAborted {
|
||||||
status: "aborted";
|
status: "aborted";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +30,7 @@ export interface FetchError {
|
|||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
|
export type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
|
||||||
|
|
||||||
async function myfetch(
|
async function myfetch(
|
||||||
url: string,
|
url: string,
|
||||||
@ -124,21 +126,31 @@ async function json<T>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToplevelResponse = {
|
export interface ToplevelResponse {
|
||||||
timeZoneName: string;
|
timeZoneName: string;
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
session: Session | undefined;
|
session: Session | undefined;
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Fetches the top-level API data. */
|
/** Fetches the top-level API data. */
|
||||||
export async function toplevel(init: RequestInit) {
|
export async function toplevel(init: RequestInit) {
|
||||||
return await json<ToplevelResponse>("/api/", init);
|
const resp = await json<ToplevelResponse>("/api/?days=true", init);
|
||||||
|
if (resp.status === "success") {
|
||||||
|
resp.response.cameras.forEach((c) => {
|
||||||
|
for (const key in c.streams) {
|
||||||
|
const s = c.streams[key as StreamType];
|
||||||
|
s.camera = c;
|
||||||
|
s.streamType = key as StreamType;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoginRequest = {
|
export interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Logs in. */
|
/** Logs in. */
|
||||||
export async function login(req: LoginRequest, init: RequestInit) {
|
export async function login(req: LoginRequest, init: RequestInit) {
|
||||||
@ -152,9 +164,9 @@ export async function login(req: LoginRequest, init: RequestInit) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogoutRequest = {
|
export interface LogoutRequest {
|
||||||
csrf: string;
|
csrf: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Logs out. */
|
/** Logs out. */
|
||||||
export async function logout(req: LogoutRequest, init: RequestInit) {
|
export async function logout(req: LogoutRequest, init: RequestInit) {
|
||||||
@ -167,3 +179,88 @@ export async function logout(req: LogoutRequest, init: RequestInit) {
|
|||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Recording {
|
||||||
|
startId: number;
|
||||||
|
endId?: number;
|
||||||
|
firstUncommited?: number;
|
||||||
|
growing?: boolean;
|
||||||
|
openId: number;
|
||||||
|
startTime90k: number;
|
||||||
|
endTime90k: number;
|
||||||
|
videoSampleEntryId: number;
|
||||||
|
videoSamples: number;
|
||||||
|
sampleFileBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoSampleEntry {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pixelHSpacing?: number;
|
||||||
|
pixelVSpacing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingsRequest {
|
||||||
|
cameraUuid: string;
|
||||||
|
stream: StreamType;
|
||||||
|
startTime90k?: number;
|
||||||
|
endTime90k?: number;
|
||||||
|
split90k?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingsResponse {
|
||||||
|
recordings: Recording[];
|
||||||
|
videoSampleEntries: { [id: number]: VideoSampleEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(baseUrl: string, params: { [key: string]: any }): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const k in params) {
|
||||||
|
const v = params[k];
|
||||||
|
if (v !== undefined) {
|
||||||
|
p.append(k, v.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ps = p.toString();
|
||||||
|
return ps !== "" ? `${baseUrl}?${ps}` : baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordings(req: RecordingsRequest, init: RequestInit) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (req.startTime90k !== undefined) {
|
||||||
|
p.append("startTime90k", req.startTime90k.toString());
|
||||||
|
}
|
||||||
|
if (req.endTime90k !== undefined) {
|
||||||
|
p.append("endTime90k", req.endTime90k.toString());
|
||||||
|
}
|
||||||
|
if (req.split90k !== undefined) {
|
||||||
|
p.append("split90k", req.split90k.toString());
|
||||||
|
}
|
||||||
|
const url = withQuery(
|
||||||
|
`/api/cameras/${req.cameraUuid}/${req.stream}/recordings`,
|
||||||
|
{
|
||||||
|
startTime90k: req.startTime90k,
|
||||||
|
endTime90k: req.endTime90k,
|
||||||
|
split90k: req.split90k,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return await json<RecordingsResponse>(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordingUrl(
|
||||||
|
cameraUuid: string,
|
||||||
|
stream: StreamType,
|
||||||
|
r: Recording
|
||||||
|
): string {
|
||||||
|
let s = `${r.startId}`;
|
||||||
|
if (r.endId !== undefined) {
|
||||||
|
s += `-${r.endId}`;
|
||||||
|
}
|
||||||
|
if (r.firstUncommited !== undefined) {
|
||||||
|
s += `@${r.openId}`;
|
||||||
|
}
|
||||||
|
return withQuery(`/api/cameras/${cameraUuid}/${stream}/view.mp4`, {
|
||||||
|
s,
|
||||||
|
ts: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
50
ui/src/index.css
Normal file
50
ui/src/index.css
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
* Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
* SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
*/
|
||||||
|
|
||||||
|
@media (pointer: fine) {
|
||||||
|
/*
|
||||||
|
* The spacing defined at https://material.io/components/date-pickers#specs
|
||||||
|
* seems plenty big enough (on desktop). Not sure why material-ui wants
|
||||||
|
* to make it bigger but that doesn't work well with our layout.
|
||||||
|
*
|
||||||
|
* Defining this here (in a global .css file) is the first way I could find
|
||||||
|
* to override properties of .MuiPickerStaticWrapper-root. It's unfortunately
|
||||||
|
* a _parent_ of the element that gets the <DatePicker>'s className applied,
|
||||||
|
* and it doesn't seem to be exposed for a global style override
|
||||||
|
* <https://next.material-ui.com/customization/theme-components/#global-style-overrides>.
|
||||||
|
*/
|
||||||
|
.MuiPickersStaticWrapper-root {
|
||||||
|
min-width: 256px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increased specificity here so it doesn't apply to the popup time picker. */
|
||||||
|
.MuiPickersStaticWrapper-root .MuiPickerView-root {
|
||||||
|
width: 256px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiPickersCalendar-root {
|
||||||
|
min-height: 160px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiPickersCalendar-weekDayLabel {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiPickersCalendar-week {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiPickersDay-dayWithMargin {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiPickersDay-root {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
}
|
@ -5,12 +5,15 @@
|
|||||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||||
import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles";
|
import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles";
|
||||||
import StyledEngineProvider from "@material-ui/core/StyledEngineProvider";
|
import StyledEngineProvider from "@material-ui/core/StyledEngineProvider";
|
||||||
|
import LocalizationProvider from "@material-ui/lab/LocalizationProvider";
|
||||||
import "@fontsource/roboto";
|
import "@fontsource/roboto";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import { SnackbarProvider } from "./snackbars";
|
import { SnackbarProvider } from "./snackbars";
|
||||||
|
import AdapterDateFns from "@material-ui/lab/AdapterDateFns";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
const theme = createMuiTheme({
|
const theme = createMuiTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@ -29,9 +32,11 @@ ReactDOM.render(
|
|||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
<SnackbarProvider autoHideDuration={5000}>
|
<SnackbarProvider autoHideDuration={5000}>
|
||||||
<App />
|
<App />
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
|
</LocalizationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StyledEngineProvider>
|
</StyledEngineProvider>
|
||||||
|
@ -2,6 +2,13 @@
|
|||||||
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file Types from the Moonfire NVR API.
|
||||||
|
* See descriptions in <tt>design/api.md</tt>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StreamType = "main" | "sub";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
username: string;
|
username: string;
|
||||||
csrf: string;
|
csrf: string;
|
||||||
@ -10,4 +17,24 @@ export interface Session {
|
|||||||
export interface Camera {
|
export interface Camera {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
shortName: string;
|
shortName: string;
|
||||||
|
description: string;
|
||||||
|
streams: Record<StreamType, Stream>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stream {
|
||||||
|
camera: Camera; // back-reference added within api.ts.
|
||||||
|
streamType: StreamType; // likewise.
|
||||||
|
retainBytes: number;
|
||||||
|
minStartTime90k: number;
|
||||||
|
maxEndTime90k: number;
|
||||||
|
totalDuration90k: number;
|
||||||
|
totalSampleFileBytes: number;
|
||||||
|
fsBytes: number;
|
||||||
|
days: Record<string, Day>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Day {
|
||||||
|
totalDuration90k: number;
|
||||||
|
startTime90k: number;
|
||||||
|
endTime90k: number;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
"downlevelIteration": true,
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@ -20,7 +17,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"]
|
||||||
"src"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user