first draft of react-based list ui (#111)

This commit is contained in:
Scott Lamb 2021-03-05 16:56:51 -08:00
parent 2677184c58
commit 08c3246982
14 changed files with 1081 additions and 75 deletions

View File

@ -50,7 +50,7 @@ def has_license(f):
def file_has_license(filename):
with open(filename, 'r') as f:
return has_license(f)
def main(args):
if not args:
@ -59,8 +59,8 @@ def main(args):
missing = [f for f in args
if FILENAME_MATCHER.match(f) and not file_has_license(f)]
if missing:
print('The following files are missing expected copyright/license headers:')
print('\n'.join(missing))
print('The following files are missing expected copyright/license headers:', file=sys.stderr)
print('\n'.join(missing), file=sys.stderr)
sys.exit(1)

129
ui/package-lock.json generated
View File

@ -1879,17 +1879,17 @@
}
},
"@material-ui/core": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.25.tgz",
"integrity": "sha512-TtU3m3qw0FKtrDHCPmakJ25/20bGPRkLn8qjDkyCUYfg7kG4RLIwgiI0fKnF5PCDu46qL+s233+nVwAh8Iq+qw==",
"version": "5.0.0-alpha.26",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-5.0.0-alpha.26.tgz",
"integrity": "sha512-etGxHyZn8REebM8SCcRl0ZaJmm3G6+I7oZqvuZf3Gg+SB03To8SgOkc0BXqteNjt+Z4Etz+xv1iy4RU+rFzGFA==",
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/styled-engine": "5.0.0-alpha.25",
"@material-ui/styles": "5.0.0-alpha.25",
"@material-ui/system": "5.0.0-alpha.25",
"@material-ui/styles": "5.0.0-alpha.26",
"@material-ui/system": "5.0.0-alpha.26",
"@material-ui/types": "5.1.7",
"@material-ui/unstyled": "5.0.0-alpha.25",
"@material-ui/utils": "5.0.0-alpha.25",
"@material-ui/unstyled": "5.0.0-alpha.26",
"@material-ui/utils": "5.0.0-alpha.26",
"@popperjs/core": "^2.4.4",
"@types/react-transition-group": "^4.2.0",
"clsx": "^1.0.4",
@ -1898,28 +1898,53 @@
"prop-types": "^15.7.2",
"react-is": "^16.8.0 || ^17.0.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": {
"version": "5.0.0-alpha.24",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.24.tgz",
"integrity": "sha512-b9WnCARLzy3tRmSShn5iV6EYpWNLmrv9P+1ebAGxkHYvOi7ZHsk6NeL61FNx+sJ9nSrLnk+N7rTYI0h03XViPA==",
"version": "5.0.0-alpha.26",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-5.0.0-alpha.26.tgz",
"integrity": "sha512-omrMgRa90Uip1TW5KRvAoTHvdNkVgnVp8z5rF3ez1q8rgI+RgqLxyHE8ezpbN8LvWvrGU97OXDSDbcsrRUjUxw==",
"requires": {
"@babel/runtime": "^7.4.4"
}
},
"@material-ui/lab": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.25.tgz",
"integrity": "sha512-J5hYwWvVdJP8XRXz8JJs3Imn1xBQqQCCLnbxF59yHgdeGLsnw3RfJulnsxMSlSwk321hDnBlt4XmNf+scvLT0g==",
"version": "5.0.0-alpha.26",
"resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-5.0.0-alpha.26.tgz",
"integrity": "sha512-WqdLxN6v/gBOVKOwjrs+L9spmPQHRI+MMMtYoaHNY2t870OGuR8/jBxKaZVyq8G60bKHiGq8cg5ppHkQibQmyQ==",
"requires": {
"@babel/runtime": "^7.4.4",
"@date-io/date-fns": "^2.10.6",
"@date-io/dayjs": "^2.10.6",
"@date-io/luxon": "^2.10.6",
"@date-io/moment": "^2.10.6",
"@material-ui/system": "5.0.0-alpha.25",
"@material-ui/utils": "5.0.0-alpha.25",
"@material-ui/system": "5.0.0-alpha.26",
"@material-ui/utils": "5.0.0-alpha.26",
"clsx": "^1.0.4",
"prop-types": "^15.7.2",
"react-is": "^16.8.0 || ^17.0.0",
@ -1938,14 +1963,14 @@
}
},
"@material-ui/styles": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.25.tgz",
"integrity": "sha512-VODyi/JT0njmkcyRKiYPacKQmhS11bidg6AMTgAU5KjdmchFPRIl5lwIHFYeRtVEsHX9/9tE0pPlCJIhVYAYsw==",
"version": "5.0.0-alpha.26",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-5.0.0-alpha.26.tgz",
"integrity": "sha512-HL1NdzIruDkRMoPhb6qRDGYO4Bn3xM/VwE3pfZPmKyjOz7tCvtqHR5uzdI34EcodgfM/Hm/HSDB69CBTpxBt4A==",
"requires": {
"@babel/runtime": "^7.4.4",
"@emotion/hash": "^0.8.0",
"@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",
"csstype": "^3.0.2",
"hoist-non-react-statics": "^3.3.2",
@ -1958,15 +1983,29 @@
"jss-plugin-rule-value-function": "^10.0.3",
"jss-plugin-vendor-prefixer": "^10.0.3",
"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": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-5.0.0-alpha.25.tgz",
"integrity": "sha512-4TrsBfB9MMsUj8o1jGRBI4Zv1SSASDwg1lfad5SYtlGhL+0LiSDO/XhVvOvYaRi749lguux2rlrVZpIut0jgMA==",
"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.25",
"@material-ui/utils": "5.0.0-alpha.26",
"csstype": "^3.0.2",
"prop-types": "^15.7.2"
}
@ -1977,21 +2016,35 @@
"integrity": "sha512-OSpB0gEKZm5h4izTLyipb34PkfazpvusgQMDTmFkSuqcKoChTshfGejEYX6uaZ+4m5xlT5qzihE6eKA+JnjELg=="
},
"@material-ui/unstyled": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.25.tgz",
"integrity": "sha512-LqAXDhSk2GC3C3SX0U5V3U0WA6gAS5Ayrozo9Br2XtxfEDdPTDmR1R7csT+DruyP3vGBs4k4ILIUz8KfC/kjww==",
"version": "5.0.0-alpha.26",
"resolved": "https://registry.npmjs.org/@material-ui/unstyled/-/unstyled-5.0.0-alpha.26.tgz",
"integrity": "sha512-6xoakfiqtT1/KOcN3QPc5Avf8llNn2eklpKiEY4Jut6wIw5DHlGYi4jlMe6e7SvxUXTVdz0S55zR3GCLlKfcPg==",
"requires": {
"@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",
"prop-types": "^15.7.2",
"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": {
"version": "5.0.0-alpha.25",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-5.0.0-alpha.25.tgz",
"integrity": "sha512-wgW1ua+ncAU6lrE2bFhd9iU1xmpxj1KL+YRJa0PoFpFgLNVpSTCVHynMlRAERNP+CteazFHj5upkigqJpV406Q==",
"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",
@ -2066,9 +2119,9 @@
}
},
"@popperjs/core": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.8.3.tgz",
"integrity": "sha512-olsVs3lo8qKycPoWAUv4bMyoTGVXsEpLR9XxGk3LJR5Qa92a1Eg/Fj1ATdhwtC/6VMaKtsz1nSAeheD2B2Ru9A=="
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.0.tgz",
"integrity": "sha512-wjtKehFAIARq2OxK8j3JrggNlEslJfNuSm2ArteIbKyRMts2g0a7KzTxfRVNUM+O0gnBJ2hNV8nWPOYBgI1sew=="
},
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
@ -5181,6 +5234,16 @@
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",

View File

@ -5,14 +5,16 @@
"dependencies": {
"@emotion/react": "^11.1.5",
"@emotion/styled": "^11.1.5",
"@material-ui/core": "^5.0.0-alpha.25",
"@material-ui/icons": "^5.0.0-alpha.24",
"@material-ui/lab": "^5.0.0-alpha.25",
"@fontsource/roboto": "^4.2.1",
"@material-ui/core": "^5.0.0-alpha.26",
"@material-ui/icons": "^5.0.0-alpha.26",
"@material-ui/lab": "^5.0.0-alpha.26",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.22",
"@types/react": "^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",
"react": "^17.0.1",
"react-dom": "^17.0.1",

View File

@ -8,7 +8,9 @@ import * as api from "./api";
import MoonfireMenu from "./AppMenu";
import Login from "./Login";
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 =
| "logged-in"
@ -18,6 +20,8 @@ type LoginState =
function App() {
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 [loginState, setLoginState] = useState<LoginState>("not-logged-in");
const [error, setError] = useState<api.FetchError | null>(null);
@ -71,6 +75,8 @@ function App() {
resp.response.session === undefined ? "not-logged-in" : "logged-in"
);
setSession(resp.response.session || null);
setCameras(resp.response.cameras);
setTimeZoneName(resp.response.timeZoneName);
}
};
console.debug("Toplevel fetch num", fetchSeq);
@ -80,17 +86,18 @@ function App() {
abort.abort();
};
}, [fetchSeq]);
return (
<>
<MoonfireMenu
session={session}
setSession={setSession}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
/>
<AppBar position="static">
<MoonfireMenu
session={session}
setSession={setSession}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
/>
</AppBar>
<Login
onSuccess={onLoginSuccess}
open={
@ -113,6 +120,9 @@ function App() {
</p>
</Container>
)}
{cameras != null && cameras.length > 0 && (
<List cameras={cameras} timeZoneName={timeZoneName!} />
)}
</>
);
}

View File

@ -2,7 +2,6 @@
// 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 AppBar from "@material-ui/core/AppBar";
import Button from "@material-ui/core/Button";
import IconButton from "@material-ui/core/IconButton";
import Menu from "@material-ui/core/Menu";
@ -28,6 +27,7 @@ interface Props {
setSession: (session: Session | null) => void;
requestLogin: () => void;
logout: () => void;
menuClick?: () => void;
}
// https://material-ui.com/components/app-bar/
@ -56,9 +56,14 @@ function MoonfireMenu(props: Props) {
};
return (
<AppBar position="static">
<>
<Toolbar variant="dense">
<IconButton edge="start" color="inherit" aria-label="menu">
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={props.menuClick}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
@ -101,7 +106,7 @@ function MoonfireMenu(props: Props) {
</div>
)}
</Toolbar>
</AppBar>
</>
);
}

View 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;

View 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
View 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
View 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;

View File

@ -13,12 +13,14 @@
import { Camera, Session } from "./types";
interface FetchSuccess<T> {
export type StreamType = "main" | "sub";
export interface FetchSuccess<T> {
status: "success";
response: T;
}
interface FetchAborted {
export interface FetchAborted {
status: "aborted";
}
@ -28,7 +30,7 @@ export interface FetchError {
httpStatus?: number;
}
type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
export type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
async function myfetch(
url: string,
@ -124,21 +126,31 @@ async function json<T>(
};
}
export type ToplevelResponse = {
export interface ToplevelResponse {
timeZoneName: string;
cameras: Camera[];
session: Session | undefined;
};
}
/** Fetches the top-level API data. */
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;
password: string;
};
}
/** Logs in. */
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;
};
}
/** Logs out. */
export async function logout(req: LogoutRequest, init: RequestInit) {
@ -167,3 +179,88 @@ export async function logout(req: LogoutRequest, init: RequestInit) {
...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
View 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;
}
}

View File

@ -5,12 +5,15 @@
import CssBaseline from "@material-ui/core/CssBaseline";
import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles";
import StyledEngineProvider from "@material-ui/core/StyledEngineProvider";
import LocalizationProvider from "@material-ui/lab/LocalizationProvider";
import "@fontsource/roboto";
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import ErrorBoundary from "./ErrorBoundary";
import { SnackbarProvider } from "./snackbars";
import AdapterDateFns from "@material-ui/lab/AdapterDateFns";
import "./index.css";
const theme = createMuiTheme({
palette: {
@ -29,9 +32,11 @@ ReactDOM.render(
<CssBaseline />
<ThemeProvider theme={theme}>
<ErrorBoundary>
<SnackbarProvider autoHideDuration={5000}>
<App />
</SnackbarProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<SnackbarProvider autoHideDuration={5000}>
<App />
</SnackbarProvider>
</LocalizationProvider>
</ErrorBoundary>
</ThemeProvider>
</StyledEngineProvider>

View File

@ -2,6 +2,13 @@
// 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
/**
* @file Types from the Moonfire NVR API.
* See descriptions in <tt>design/api.md</tt>.
*/
export type StreamType = "main" | "sub";
export interface Session {
username: string;
csrf: string;
@ -10,4 +17,24 @@ export interface Session {
export interface Camera {
uuid: 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;
}

View File

@ -1,11 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"downlevelIteration": true,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@ -20,7 +17,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"include": ["src"]
}