start a new React-based UI (#111)

This doesn't do much yet but should provide a better foundation for
improvement than the jQuery UI, as described in the github issue.
This commit is contained in:
Scott Lamb 2021-01-31 21:55:25 -08:00
parent c547a49ac8
commit f281922359
67 changed files with 11454 additions and 8052 deletions

View File

@ -1,4 +1,4 @@
/server/target
/ui/dist
/ui/build
/ui/node_modules
/ui/yarn-error.log

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
# 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.
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
"""Checks that expected header lines are present.
Call in either of two modes:
@ -19,14 +19,14 @@ import re
import sys
# Filenames matching this regexp are expected to have the header lines.
FILENAME_MATCHER = re.compile(r'.*\.(py|rs|sh|sql)$')
FILENAME_MATCHER = re.compile(r'.*\.([jt]sx?|html|css|py|rs|sh|sql)$')
MAX_LINE_COUNT = 10
EXPECTED_LINES = [
re.compile(r'This file is part of Moonfire NVR, a security camera network video recorder\.'),
re.compile(r'Copyright \(C\) 20\d{2} The Moonfire NVR Authors; see AUTHORS and LICENSE\.txt\.'),
re.compile(r'SPDX-License-Identifier: GPL-v3\.0-or-later WITH GPL-3\.0-linking-exception\.'),
re.compile(r'SPDX-License-Identifier: GPL-v3\.0-or-later WITH GPL-3\.0-linking-exception\.?'),
]
def has_license(f):

View File

@ -21,49 +21,51 @@ jobs:
extra_components: rustfmt
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cache
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
server/target
key: ${{ matrix.rust }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install libavcodec-dev libavformat-dev libavutil-dev libncurses-dev libsqlite3-dev pkgconf
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
override: true
components: ${{ matrix.extra_components }}
- name: Test
run: cd server && cargo test ${{ matrix.extra_args }} --all
- name: Check formatting
if: matrix.rust == 'stable'
run: cd server && cargo fmt --all -- --check
- name: Checkout
uses: actions/checkout@v2
- name: Cache
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
server/target
key: ${{ matrix.rust }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install libavcodec-dev libavformat-dev libavutil-dev libncurses-dev libsqlite3-dev pkgconf
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
override: true
components: ${{ matrix.extra_components }}
- name: Test
run: cd server && cargo test ${{ matrix.extra_args }} --all
- name: Check formatting
if: matrix.rust == 'stable'
run: cd server && cargo fmt --all -- --check
js:
name: Build and lint Javascript frontend
name: Build, test, lint, and check formatting of Javascript frontend
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: cd ui && npm ci
- run: cd ui && npm run build
- run: cd ui && npm run lint
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "14"
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: cd ui && npm ci
- run: cd ui && npm run build
- run: cd ui && npm run test
- run: cd ui && npm run lint
- run: cd ui && npm run check-format
license:
name: Check copyright/license headers
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
- run: find . -type f -print0 | xargs -0 .github/workflows/check-license.py
- name: Checkout
uses: actions/checkout@v2
- run: find . -type f -print0 | xargs -0 .github/workflows/check-license.py

3
.gitignore vendored
View File

@ -1,6 +1,3 @@
.DS_Store
*.swp
/server/target
/ui/dist
/ui/node_modules
/ui/yarn-error.log

38
.vscode/settings.json vendored
View File

@ -1,11 +1,33 @@
{
// General settings (notably including Javascript/Typescript).
"editor.detectIndentation": false,
"editor.tabSize": 2,
"editor.rulers": [
{
"column": 80,
"color": "#cc8888"
}
],
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true,
// Rust-specific overrides.
"[rust]": {
"editor.tabSize": 4,
"editor.rulers": [
80,
{
"column": 100,
"color": "#cc8888"
}
80,
{
"column": 100,
"color": "#cc8888"
}
],
"rust-analyzer.inlayHints.enable": false
}
// It seems like rust-analyzer is supposed to be able to format
// Rust files, but with "matklad.rust-analyzer" here, VS Code says
// "There is no formatter for 'rust' files installed."
"editor.defaultFormatter": "matklad.rust-analyzer"
//"editor.defaultFormatter": null
},
"rust-analyzer.inlayHints.enable": false
}

View File

@ -68,7 +68,7 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
rm -rf /var/lib/apt/lists/* && \
ln -s moonfire-nvr /usr/local/bin/nvr
COPY --from=build-server /usr/local/bin/moonfire-nvr /usr/local/bin/moonfire-nvr
COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/dist /usr/local/lib/moonfire-nvr/ui
COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /usr/local/lib/moonfire-nvr/ui
# The install instructions say to use --user in the docker run commandline.
# Specify a non-root user just in case someone forgets.

View File

@ -20,8 +20,10 @@ this in the webpack documentation.
Checkout the branch you want to work on and type
$ cd ui
$ npm run start
```
$ cd ui
$ npm run start
```
This will pack and prepare a development setup. By default the development
server that serves up the web page(s) will listen on
@ -39,35 +41,29 @@ process, but some will show up in the browser console, or both.
## Overriding defaults
The configuration understands these environment variables:
The UI is setup with [Create React App](https://create-react-app.dev/).
`npm run start` will honor any of the environment variables described in their
[Advanced Configuration](https://create-react-app.dev/docs/advanced-configuration/),
as well as Moonfire NVR's custom `PROXY_TARGET` variable. Quick reference:
| variable | description | default |
| :------------------ | :------------------------------------------ | :----------------------- |
| `MOONFIRE_URL` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
| `MOONFIRE_DEV_PORT` | port to listen on | 3000 |
| `MOONFIRE_DEV_HOST` | host/IP to listen on (or `0.0.0.0`) | `localhost` (1) |
(1) Moonfire NVR's `webpack/dev.config.js` has no default value for
`MOONFIRE_DEV_HOST`. `webpack-dev-server` itself has a default of `localhost`,
as described
[here](https://webpack.js.org/configuration/dev-server/#devserverhost).
| variable | description | default |
| :------------- | :----------------------------------------------------------------------- | :----------------------- |
| `PROXY_TARGET` | base URL of the backing Moonfire NVR server (see `ui/src/setupProxy.js`) | `http://localhost:8080/` |
| `PORT` | port to listen on | 3000 |
| `HOST` | host/IP to listen on (or `0.0.0.0` for all) | `0.0.0.0` |
Thus one could connect to a remote Moonfire NVR by specifying its URL as
follows:
$ MOONFIRE_URL=https://nvr.example.com/ npm run start
```
$ PROXY_TARGET=https://nvr.example.com/ npm run start
```
This allows you to test a new UI against your stable, production Moonfire NVR
installation with real data.
The default `MOONFIRE_DEV_HOST` is suitable for connecting to the proxy server
from a browser running on the same machine. If you want your server to be
externally accessible, you may want to bind to `0.0.0.0` instead:
$ MOONFIRE_DEV_HOST=0.0.0.0 npm run start
Be careful, though: it's insecure to send your production credentials over a
non-`https` connection, as described more below.
You can also set environment variables in `.env` files, as described in
[Adding Custom Environment Variables](https://create-react-app.dev/docs/adding-custom-environment-variables/).
## A note on `https`
@ -96,11 +92,10 @@ but has a couple caveats:
* if you alternate between proxying to a test Moonfire NVR
installation and a real one, your browser won't know the difference. It
will supply whichever credentials were sent to it last.
* if you connect via a host other than localhost (and set
`MOONFIRE_DEV_HOST` to allow this), your browser will have a production
cookie that it's willing to send to a remote host over a non-`https`
connection. If you ever load this website using an untrustworthy DNS
server, your credentials can be compromised.
* if you connect via a host other than localhost, your browser will have a
production cookie that it's willing to send to a remote host over a
non-`https` connection. If you ever load this website using an
untrustworthy DNS server, your credentials can be compromised.
We might add support for method 3 in the future. It's less convenient to
configure but can avoid these problems.

View File

@ -1233,12 +1233,18 @@ struct StaticFileRequest<'a> {
impl<'a> StaticFileRequest<'a> {
fn parse(path: &'a str) -> Option<Self> {
if !path.starts_with("/") {
if !path.starts_with("/") || path == "/index.html" {
return None;
}
let (path, immutable) = match &path[1..] {
// These well-known URLs don't have content hashes in them, and
// thus aren't immutable.
"" => ("index.html", false),
"robots.txt" => ("robots.txt", false),
"site.webmanifest" => ("site.webmanifest", false),
// Everything else should.
p => (p, true),
};
@ -1253,7 +1259,10 @@ impl<'a> StaticFileRequest<'a> {
"js" | "map" => "text/javascript",
"json" => "application/json",
"png" => "image/png",
"webapp" => "application/x-web-app-manifest+json",
"webmanifest" => "application/manifest+json",
"txt" => "text/plain",
"woff2" => "font/woff2",
"css" => "text/css",
_ => return None,
};

View File

@ -1,21 +0,0 @@
{
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"env": {
"es6": true,
"browser": true,
"node": true
},
"extends": "google",
"rules": {
"init-declarations": ["error", "always"],
"no-catch-shadow": ["error"],
"no-delete-var": ["error"],
"no-shadow": ["error", { "builtinGlobals": false, "hoist": "functions", "allow": [] }],
"no-shadow-restricted-names": ["error"],
"no-undef": ["error", {"typeof": true}],
"no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false }]
}
}

24
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
ui/README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

13776
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,68 @@
{
"author": {
"name": "Scott Lamb",
"email": "slamb@slamb.org",
"url": "https://www.slamb.org/"
},
"bugs": {
"url": "https://github.com/scottlamb/moonfire-nvr/issues"
"name": "ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.22",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"fontsource-roboto": "^4.0.0",
"gzipper": "^4.4.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.1",
"typescript": "^4.1.3"
},
"scripts": {
"start": "webpack-dev-server --mode development --config webpack/dev.config.js --progress",
"build": "webpack --mode production --config webpack/prod.config.js",
"start": "react-scripts start",
"build": "react-scripts build && gzipper compress --exclude=png,woff2 --remove-larger ./build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"format": "prettier --write src/ public/",
"check-format": "prettier --check src/ public/",
"lint": "eslint src"
},
"dependencies": {
"jquery": "^3.2.1",
"jquery-ui": "^1.12.1",
"moment-timezone": "^0.5.13"
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"no-restricted-imports": [
"error",
{
"name": "@material-ui/core",
"message": "Please use the 'import Button from \"material-ui/core/Button\";' style instead; see https://material-ui.com/guides/minimizing-bundle-size/#option-1"
},
{
"name": "@material-ui/icons",
"message": "Please use the 'import MenuIcon from \"material-ui/icons/Menu\";' style instead; see https://material-ui.com/guides/minimizing-bundle-size/#option-1"
}
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"homepage": "https://github.com/scottlamb/moonfire-nvr",
"license": "GPL-3.0",
"name": "moonfire-nvr",
"description": "security camera network video recorder",
"repository": "scottlamb/moonfire-nvr",
"version": "0.1.0",
"devDependencies": {
"@babel/core": "^7.8.6",
"@babel/preset-env": "^7.8.6",
"babel-loader": "^8.0.6",
"babel-plugin-transform-imports": "^2.0.0",
"clean-webpack-plugin": "^3.0.0",
"compression-webpack-plugin": "^3.1.0",
"css-loader": "^3.4.2",
"eslint": "^7.0.0",
"eslint-config-google": "^0.14.0",
"favicons-webpack-plugin": "^4.2.0",
"file-loader": "^6.0.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"prettier": "2.0.5",
"style-loader": "^1.1.3",
"webpack": "^4.41.6",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3",
"webpack-merge": "^4.2.2"
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.6.3",
"http-proxy-middleware": "^1.0.6",
"msw": "^0.26.1",
"prettier": "2.2.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="933.333" height="933.333" viewBox="0 0 700.000000 700.000000"><path d="M327 1c-74.1 5.4-142.5 32.4-198.9 78.8C9.1 177.6-31.7 341 27.1 484.1c33.1 80.5 99 148.7 178.3 184.2 93 41.8 196.2 41.8 289.2 0 31.7-14.2 67.6-37.9 92.8-61.2 36.8-34.1 64.2-73 84.1-119.7 31.7-74.2 36.7-158.7 14-236.1-22.2-75.4-70.5-142.5-135.1-187.9C501.5 29.2 444.4 8 384.5 2.1 369.2.5 340.9 0 327 1z"/></svg>

After

Width:  |  Height:  |  Size: 439 B

46
ui/public/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<!--
- 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
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/favicons/apple-touch-icon-94a09b5d2ddb5af47.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicons/favicon-32x32-ab95901a9e0d040e2.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicons/favicon-16x16-b16b3f2883aacf9f1.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<link
rel="mask-icon"
href="%PUBLIC_URL%/favicons/safari-pinned-tab-9792c2c82f04639f8.svg"
color="#e04e1b"
/>
<meta name="theme-color" content="#e04e1b" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
<meta name="description" content="security camera network video recorder" />
<title>Moonfire NVR</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

3
ui/public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

View File

@ -0,0 +1,21 @@
{
"name": "Moonfire NVR",
"short_name": "Moonfire",
"description": "security camera network video recorder",
"icons": [
{
"src": "/favicons/android-chrome-192x192-22fa756c4c8a94dde.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicons/android-chrome-512x512-0403b1c77057918bb.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#e04e1b",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "."
}

12
ui/src/App.test.tsx Normal file
View File

@ -0,0 +1,12 @@
// 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 { screen } from "@testing-library/react";
import App from "./App";
import { renderWithCtx } from "./testutil";
test("instantiate", async () => {
renderWithCtx(<App />);
expect(screen.getByText(/Moonfire NVR/)).toBeInTheDocument();
});

120
ui/src/App.tsx Normal file
View File

@ -0,0 +1,120 @@
// 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 Container from "@material-ui/core/Container";
import React, { useEffect, useState } from "react";
import * as api from "./api";
import MoonfireMenu from "./AppMenu";
import Login from "./Login";
import { useSnackbars } from "./snackbars";
import { Session } from "./types";
type LoginState =
| "logged-in"
| "not-logged-in"
| "server-requires-login"
| "user-requested-login";
function App() {
const [session, setSession] = useState<Session | null>(null);
const [fetchSeq, setFetchSeq] = useState(0);
const [loginState, setLoginState] = useState<LoginState>("not-logged-in");
const [error, setError] = useState<api.FetchError | null>(null);
const needNewFetch = () => setFetchSeq((seq) => seq + 1);
const snackbars = useSnackbars();
const onLoginSuccess = () => {
setLoginState("logged-in");
needNewFetch();
};
const logout = async () => {
const resp = await api.logout(
{
csrf: session!.csrf,
},
{}
);
switch (resp.status) {
case "aborted":
break;
case "error":
snackbars.enqueue({
message: "Logout failed: " + resp.message,
});
break;
case "success":
setSession(null);
needNewFetch();
break;
}
};
useEffect(() => {
const abort = new AbortController();
const doFetch = async (signal: AbortSignal) => {
const resp = await api.toplevel({ signal });
switch (resp.status) {
case "aborted":
break;
case "error":
if (resp.httpStatus === 401) {
setLoginState("server-requires-login");
return;
}
setError(resp);
break;
case "success":
setError(null);
setLoginState(
resp.response.session === undefined ? "not-logged-in" : "logged-in"
);
setSession(resp.response.session || null);
}
};
console.debug("Toplevel fetch num", fetchSeq);
doFetch(abort.signal);
return () => {
console.log("Aborting toplevel fetch num", fetchSeq);
abort.abort();
};
}, [fetchSeq]);
return (
<>
<MoonfireMenu
session={session}
setSession={setSession}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
/>
<Login
onSuccess={onLoginSuccess}
open={
loginState === "server-requires-login" ||
loginState === "user-requested-login"
}
handleClose={() => {
setLoginState((s) =>
s === "user-requested-login" ? "not-logged-in" : s
);
}}
/>
{error != null && (
<Container>
<h2>Error querying server</h2>
<pre>{error.message}</pre>
<p>
You may find more information in the Javascript console. Try
reloading the page once you believe the problem is resolved.
</p>
</Container>
)}
</>
);
}
export default App;

108
ui/src/AppMenu.tsx Normal file
View File

@ -0,0 +1,108 @@
// 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 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";
import MenuItem from "@material-ui/core/MenuItem";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import AccountCircle from "@material-ui/icons/AccountCircle";
import MenuIcon from "@material-ui/icons/Menu";
import React from "react";
import { Session } from "./types";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
title: {
flexGrow: 1,
},
})
);
interface Props {
session: Session | null;
setSession: (session: Session | null) => void;
requestLogin: () => void;
logout: () => void;
}
// https://material-ui.com/components/app-bar/
function MoonfireMenu(props: Props) {
const classes = useStyles();
const auth = props.session !== null;
const [
accountMenuAnchor,
setAccountMenuAnchor,
] = React.useState<null | HTMLElement>(null);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchor(event.currentTarget);
};
const handleClose = () => {
console.log("handleAccountMenuClose");
setAccountMenuAnchor(null);
};
const handleLogout = () => {
// Note this close should happen before `auth` toggles, or material-ui will
// be unhappy about the anchor element not being part of the layout.
handleClose();
props.logout();
};
return (
<AppBar position="static">
<Toolbar variant="dense">
<IconButton edge="start" color="inherit" aria-label="menu">
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Moonfire NVR
</Typography>
{auth || (
<Button color="inherit" onClick={props.requestLogin}>
Log in
</Button>
)}
{auth && (
<div>
<IconButton
aria-label="account of current user"
aria-controls="primary-search-account-menu"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
size="small"
>
<AccountCircle />
</IconButton>
<Menu
anchorEl={accountMenuAnchor}
getContentAnchorEl={null}
keepMounted
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={Boolean(accountMenuAnchor)}
onClose={handleClose}
>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</div>
)}
</Toolbar>
</AppBar>
);
}
export default MoonfireMenu;

View File

@ -0,0 +1,44 @@
// 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 { render, screen } from "@testing-library/react";
import ErrorBoundary from "./ErrorBoundary";
const BuggyComponent = () => {
return [][0]; // return undefined in a way that outsmarts Typescript.
};
const ThrowsLiteralComponent = () => {
throw "simple string error"; // eslint-disable-line no-throw-literal
};
test("renders error", () => {
render(
<ErrorBoundary>
<BuggyComponent />
</ErrorBoundary>
);
const buggyComponentElement = screen.getByText(/BuggyComponent/);
expect(buggyComponentElement).toBeInTheDocument();
const sorryElement = screen.getByText(/Sorry/);
expect(sorryElement).toBeInTheDocument();
});
test("renders string error", () => {
render(
<ErrorBoundary>
<ThrowsLiteralComponent />
</ErrorBoundary>
);
const msgElement = screen.getByText(/simple string error/);
expect(msgElement).toBeInTheDocument();
});
test("renders child on success", () => {
render(<ErrorBoundary>foo</ErrorBoundary>);
const fooElement = screen.getByText(/foo/);
expect(fooElement).toBeInTheDocument();
const sorryElement = screen.queryByText(/Sorry/);
expect(sorryElement).toBeNull();
});

133
ui/src/ErrorBoundary.tsx Normal file
View File

@ -0,0 +1,133 @@
// 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 Avatar from "@material-ui/core/Avatar";
import Container from "@material-ui/core/Container";
import {
createStyles,
Theme,
WithStyles,
withStyles,
} from "@material-ui/core/styles";
import BugReportIcon from "@material-ui/icons/BugReport";
import React from "react";
interface State {
error: any;
}
const styles = (theme: Theme) =>
createStyles({
avatar: {
backgroundColor: theme.palette.secondary.main,
float: "left",
marginRight: "1em",
},
});
interface Props extends WithStyles<typeof styles> {
children: React.ReactNode;
}
/**
* A simple <a href="https://reactjs.org/docs/error-boundaries.html">error
* boundary</a> meant to go at the top level.
*
* The assumption is that any error here is a bug in the UI layer. Components
* shouldn't throw errors upward even if there are network or server problems.
*
* Limitations: as described in the React docs, error boundaries don't catch
* errors in async code / rejected Promises.
*/
class MoonfireErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: any) {
return { error };
}
componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
render() {
const { classes, children } = this.props;
if (this.state.error !== null) {
var error;
if (this.state.error.stack !== undefined) {
error = <pre>{this.state.error.stack}</pre>;
} else if (this.state.error instanceof Error) {
error = (
<>
<pre>{this.state.error.name}</pre>
<pre>{this.state.error.message}</pre>
</>
);
} else {
error = <pre>{this.state.error}</pre>;
}
return (
<Container>
<Avatar className={classes.avatar}>
<BugReportIcon color="primary" />
</Avatar>
<h1>Error</h1>
<p>
Sorry! You've found a bug in Moonfire NVR. We need a good bug report
to get it fixed. Can you help?
</p>
<h2>How to report a bug</h2>
<p>
Please open{" "}
<a href="https://github.com/scottlamb/moonfire-nvr/issues">
Moonfire NVR's issue tracker
</a>{" "}
and see if this problem has already been reported.
</p>
<h3>Can't find anything?</h3>
<p>Open a new issue with as much detail as you can:</p>
<ul>
<li>the version of Moonfire NVR you're using</li>
<li>
your environment, including:
<ul>
<li>web browser: Chrome, Firefox, Safari, etc.</li>
<li>platform: macOS, Windows, Linux, Android, iOS, etc.</li>
<li>browser extensions</li>
<li>anything special about your Moonfire NVR setup</li>
</ul>
</li>
<li>all the errors you see in your browser's Javascript console</li>
<li>steps to reproduce, if possible</li>
</ul>
<h3>Already reported?</h3>
<ul>
<li>+1 the issue so we know more people are affected.</li>
<li>add any new details you've noticed.</li>
</ul>
<h2>The error</h2>
{error}
</Container>
);
}
return children;
}
}
export default withStyles(styles)(MoonfireErrorBoundary);

110
ui/src/Login.test.tsx Normal file
View File

@ -0,0 +1,110 @@
// 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 { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { setupServer } from "msw/node";
import Login from "./Login";
import { renderWithCtx } from "./testutil";
// Set up a fake API backend.
const server = setupServer(
rest.post("/api/login", (req, res, ctx) => {
const { username, password } = req.body! as Record<string, string>;
if (username === "slamb" && password === "hunter2") {
return res(ctx.status(204));
} else if (username === "delay") {
return res(ctx.delay("infinite"));
} else if (username === "server-error") {
return res(ctx.status(503), ctx.text("server error"));
} else if (username === "network-error") {
return res.networkError("network error");
} else {
return res(ctx.status(401), ctx.text("bad credentials"));
}
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Mock out timers for snackbars.
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test("success", async () => {
const handleClose = jest.fn();
const onSuccess = jest.fn();
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "slamb");
userEvent.type(screen.getByLabelText(/Password/), "hunter2{enter}");
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
test("close while pending", async () => {
const handleClose = jest.fn();
const onSuccess = jest.fn();
const { rerender } = renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "delay");
userEvent.type(screen.getByLabelText(/Password/), "hunter2{enter}");
expect(screen.getByRole("button", { name: /Log in/ })).toBeInTheDocument();
rerender(
<Login open={false} onSuccess={onSuccess} handleClose={handleClose} />
);
await waitFor(() =>
expect(
screen.queryByRole("button", { name: /Log in/ })
).not.toBeInTheDocument()
);
});
test("bad credentials", async () => {
const handleClose = jest.fn();
const onSuccess = jest.fn();
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "slamb");
userEvent.type(screen.getByLabelText(/Password/), "wrong{enter}");
await screen.findByText(/bad credentials/);
expect(onSuccess).toHaveBeenCalledTimes(0);
});
test("server error", async () => {
const handleClose = jest.fn();
const onSuccess = jest.fn();
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "server-error");
userEvent.type(screen.getByLabelText(/Password/), "asdf{enter}");
await screen.findByText(/server error/);
await waitFor(() =>
expect(screen.queryByText(/server error/)).not.toBeInTheDocument()
);
expect(onSuccess).toHaveBeenCalledTimes(0);
});
test("network error", async () => {
const handleClose = jest.fn();
const onSuccess = jest.fn();
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "network-error");
userEvent.type(screen.getByLabelText(/Password/), "asdf{enter}");
await screen.findByText(/network error/);
await waitFor(() =>
expect(screen.queryByText(/network error/)).not.toBeInTheDocument()
);
expect(onSuccess).toHaveBeenCalledTimes(0);
});

170
ui/src/Login.tsx Normal file
View File

@ -0,0 +1,170 @@
// 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 Avatar from "@material-ui/core/Avatar";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogTitle from "@material-ui/core/DialogTitle";
import { makeStyles, Theme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import Alert from "@material-ui/lab/Alert";
import React, { useEffect } from "react";
import * as api from "./api";
import { useSnackbars } from "./snackbars";
const useStyles = makeStyles((theme: Theme) => ({
avatar: {
backgroundColor: theme.palette.secondary.main,
},
noError: {
visibility: "hidden",
},
}));
interface Props {
open: boolean;
onSuccess: () => void;
handleClose: () => void;
}
/**
* Dialog for logging in.
*
* This is similar to <a
* href="https://github.com/mui-org/material-ui/tree/master/docs/src/pages/getting-started/templates/sign-in">the
* material-ui sign-in template</a>. On 401 error, it displays an error near
* the submit button; on other errors, it uses a (transient) snackbar.
*
* This doesn't quite follow Chromium's <a
* href="https://www.chromium.org/developers/design-documents/create-amazing-password-forms">creating</a>
* amazing password forms</a> recommendations: it doesn't prompt a navigation
* event on success. It's simpler to not mess with the history, and the current
* method appears to work with Chrome 88's built-in password manager. To be
* revisited if this causes problems.
*
* {@param open} should be true only when not logged in.
* {@param onSuccess} called when the user is successfully logged in and the
* cookie is set. The caller will have to do a new top-level API request to
* retrieve the CSRF token, as well as other data that wasn't available before
* logging in.
* {@param handleClose} called when a close was requested (by pressing escape
* or clicking outside the dialog). If the top-level API request fails when
* not logged in (the server is running without
* <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this.
*/
const Login = ({ open, onSuccess, handleClose }: Props) => {
const classes = useStyles();
const snackbars = useSnackbars();
// This is a simple uncontrolled form; use refs.
const usernameRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState<api.LoginRequest | null>(null);
useEffect(() => {
if (pending === null) {
return;
}
let abort = new AbortController();
const send = async (signal: AbortSignal) => {
let response = await api.login(pending, { signal });
switch (response.status) {
case "aborted":
break;
case "error":
if (response.httpStatus === 401) {
setError(response.message);
} else {
snackbars.enqueue({
message: response.message,
key: "login-error",
});
}
setPending(null);
break;
case "success":
setPending(null);
onSuccess();
}
};
send(abort.signal);
return () => {
abort.abort();
};
}, [pending, onSuccess, snackbars]);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Suppress duplicate login attempts when latency is high.
// It'd be nice to also provide a visual indication, but we might
// just wait for LoadingButton in material-ui 5 rather than reinventing it.
if (pending !== null) {
return;
}
setPending({
username: usernameRef.current!.value,
password: passwordRef.current!.value,
});
};
return (
<Dialog
onClose={handleClose}
aria-labelledby="login-title"
open={open}
maxWidth="sm"
fullWidth={true}
>
<DialogTitle id="login-title">
<Avatar className={classes.avatar}>
<LockOutlinedIcon />
</Avatar>
Log in
</DialogTitle>
<form onSubmit={onSubmit}>
<TextField
id="username"
label="Username"
variant="filled"
required
autoComplete="username"
fullWidth
inputRef={usernameRef}
/>
<TextField
id="password"
label="Password"
variant="filled"
type="password"
required
autoComplete="current-password"
fullWidth
inputRef={passwordRef}
/>
{/* reserve space for an error; show when there's something to see */}
<Alert
severity="error"
className={error === null ? classes.noError : undefined}
>
{error}
</Alert>
<DialogActions>
<Button type="submit" variant="contained" color="secondary">
Log in
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default Login;

View File

@ -1,410 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018-2020 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// TODO: test abort.
// TODO: add error bar on fetch failure.
// TODO: live updating.
import $ from 'jquery';
// tooltip needs:
// css.structure: ../../themes/base/core.css
// css.structure: ../../themes/base/tooltip.css
// css.theme: ../../themes/base/theme.css
import 'jquery-ui/themes/base/core.css';
import 'jquery-ui/themes/base/tooltip.css';
import 'jquery-ui/themes/base/theme.css';
// This causes our custom css to be loaded after the above!
import './index.css';
// Get ui widgets themselves
import 'jquery-ui/ui/widgets/tooltip';
import Camera from './lib/models/Camera';
import CalendarView from './lib/views/CalendarView';
import VideoDialogView from './lib/views/VideoDialogView';
import NVRSettingsView from './lib/views/NVRSettingsView';
import RecordingFormatter from './lib/support/RecordingFormatter';
import StreamSelectorView from './lib/views/StreamSelectorView';
import StreamView from './lib/views/StreamView';
import TimeFormatter from './lib/support/TimeFormatter';
import TimeStamp90kFormatter from './lib/support/TimeStamp90kFormatter';
import MoonfireAPI from './lib/MoonfireAPI';
const api = new MoonfireAPI();
let streamViews = null; // StreamView objects
let calendarView = null; // CalendarView object
let loginDialog = null;
/**
* Currently selected time format specification.
*
* @type {String}
*/
const timeFmt = 'YYYY-MM-DD HH:mm:ss';
/**
* Currently active time formatter.
* This is lazy initialized at the point we receive the timezone information
* and never changes afterwards, except possibly for changing the timezone.
*
* @type {[type]}
*/
let timeFormatter = null;
/**
* Currently active time formatter for internal time format.
* This is lazy initialized at the point we receive the timezone information
* and never changes afterwards, except possibly for changing the timezone.
*
* @type {[type]}
*/
let timeFormatter90k = null;
/**
* Globally set a new timezone for the app.
*
* @param {String} timeZone Timezone name
*/
function newTimeZone(timeZone) {
timeFormatter = new TimeFormatter(timeFmt, timeZone);
timeFormatter90k = new TimeStamp90kFormatter(timeZone);
}
/**
* Globally set a new time format for the app.
*
* @param {String} format Time format specification
*/
function newTimeFormat(format) {
timeFormatter = new TimeFormatter(format, timeFormatter.tz);
}
/**
* Event handler for clicking on a video.
*
* A 'dialog' object is attached to the body of the dom and it
* properly initialized with the corrcet src url.
*
* @param {NVRSettings} nvrSettingsView NVRSettingsView in effect
* @param {object} camera Object for the camera
* @param {String} streamType "main" or "sub"
* @param {object} range Range Object
* @param {object} recording Recording object
* @return {void}
*/
function onSelectVideo(nvrSettingsView, camera, streamType, range, recording) {
console.log('Recording clicked: ', recording);
const trimmedRange = recording.range90k(nvrSettingsView.trim ? range : null);
const url = api.videoPlayUrl(
camera.uuid,
streamType,
recording,
trimmedRange,
nvrSettingsView.timeStampTrack
);
const [
formattedStart,
formattedEnd,
] = timeFormatter90k.formatSameDayShortened(
trimmedRange.startTime90k,
trimmedRange.endTime90k
);
const videoTitle =
camera.shortName + ', ' + formattedStart + ' to ' + formattedEnd;
let width = recording.videoSampleEntryWidth *
recording.videoSampleEntryPaspHSpacing /
recording.videoSampleEntryPaspVSpacing;
const maxWidth = window.innerWidth * 3 / 4;
while (width > maxWidth) {
width /= 2;
}
new VideoDialogView()
.attach($('body'))
.play(videoTitle, width, url);
}
/**
* Fetch stream view data for a given date/time range.
*
* @param {Range90k} selectedRange Desired time range
* @param {Number} videoLength Desired length of video segments, or Infinity
*/
function fetch(selectedRange, videoLength) {
if (selectedRange.startTime90k === null) {
return;
}
console.log(
'Fetching> ' +
selectedRange.formatTimeStamp90k(selectedRange.startTime90k) +
' to ' +
selectedRange.formatTimeStamp90k(selectedRange.endTime90k)
);
for (const streamView of streamViews) {
const url = api.recordingsUrl(
streamView.camera.uuid,
streamView.streamType,
selectedRange.startTime90k,
selectedRange.endTime90k,
videoLength
);
if (streamView.recordingsReq !== null) {
/*
* If there is another request, it would be because settings changed
* and so an abort is to make room for this new request, now necessary
* for the changed situation.
*/
streamView.recordingsReq.abort();
}
streamView.delayedShowLoading(500);
const r = api.request(url);
streamView.recordingsUrl = url;
streamView.recordingsReq = r;
streamView.recordingsRange = selectedRange.range90k();
r.always(function() {
streamView.recordingsReq = null;
});
r
.then(function(data /* , status, req */) {
// Sort recordings in descending order.
data.recordings.sort(function(a, b) {
return b.startId - a.startId;
});
console.log(
'Fetched results for "%s-%s" > updating recordings',
streamView.camera.shortName, streamView.streamType
);
streamView.recordingsJSON = data;
})
.catch(function(data, status, err) {
console.error(url, ' load failed: ', status, ': ', err);
});
}
}
/**
* Updates the session bar at the top of the page.
*
* @param {Object} session the "session" key of the main API request's JSON,
* or null.
*/
function updateSession(session) {
const sessionBar = $('#session');
sessionBar.empty();
if (session === null || session === undefined) {
sessionBar.hide();
return;
}
sessionBar.append($('<span id="session-username" />').text(session.username));
const logout = $('<a id="logout">logout</a>');
logout.click(() => {
api
.logout(session.csrf)
.done(() => {
onReceivedTopLevel(null);
loginDialog.dialog('open');
});
});
sessionBar.append(' | ', logout);
sessionBar.show();
}
/**
* Initialize the page after receiving top-level data.
*
* Sets the following globals:
* zone - timezone from data received
* streamViews - array of views, one per stream
*
* Builds the dom for the left side controllers
*
* @param {Object} data JSON resulting from the main API request /api/?days=
* or null if the request failed.
*/
function onReceivedTopLevel(data) {
if (data === null) {
data = {cameras: [], timeZoneName: null};
}
newTimeZone(data.timeZoneName);
updateSession(data.session);
// Set up controls and values
const nvrSettingsView = new NVRSettingsView();
nvrSettingsView.onVideoLengthChange = (vl) =>
fetch(calendarView.selectedRange, vl);
nvrSettingsView.onTimeFormatChange = (format) =>
streamViews.forEach((view) => (view.timeFormat = format));
nvrSettingsView.onTrimChange = (t) =>
streamViews.forEach((view) => (view.trimmed = t));
newTimeFormat(nvrSettingsView.timeFormatString);
calendarView = new CalendarView({timeZone: timeFormatter.tz});
calendarView.onRangeChange = (selectedRange) =>
fetch(selectedRange, nvrSettingsView.videoLength);
const streamsParent = $('#streams');
const videos = $('#videos');
streamsParent.empty();
videos.empty();
streamViews = [];
const streamSelectorCameras = [];
for (const cameraJson of data.cameras) {
const camera = new Camera(cameraJson);
const cameraStreams = {};
Object.keys(camera.streams).forEach((streamType) => {
const sv = new StreamView(
camera,
streamType,
new RecordingFormatter(timeFormatter.formatStr, timeFormatter.tz),
nvrSettingsView.trim,
videos);
sv.onRecordingClicked = (recordingModel) => {
console.log('Recording clicked', recordingModel);
onSelectVideo(
nvrSettingsView,
camera,
streamType,
calendarView.selectedRange,
recordingModel
);
};
streamViews.push(sv);
cameraStreams[streamType] = sv;
});
streamSelectorCameras.push({
camera: camera,
streamViews: cameraStreams,
});
};
// Create stream enable checkboxes
const streamSelector =
new StreamSelectorView(streamSelectorCameras, streamsParent);
streamSelector.onChange = () => calendarView.initializeWith(streamViews);
calendarView.initializeWith(streamViews);
console.log('Loaded: ' + streamViews.length + ' stream views');
}
/**
* Handles the submit action on the login form.
*/
function sendLoginRequest() {
if (loginDialog.pending) {
return;
}
const username = $('#login-username').val();
const password = $('#login-password').val();
const submit = $('#login-submit');
const error = $('#login-error');
error.empty();
error.removeClass('ui-state-highlight');
submit.button('option', 'disabled', true);
loginDialog.pending = true;
console.info('logging in as', username);
api
.login(username, password)
.done(() => {
console.info('login successful');
loginDialog.dialog('close');
sendTopLevelRequest();
})
.catch((e) => {
console.info('login failed:', e);
error.show();
error.addClass('ui-state-highlight');
error.text(e.responseText);
})
.always(() => {
submit.button('option', 'disabled', false);
loginDialog.pending = false;
});
}
/** Sends the top-level api request. */
function sendTopLevelRequest() {
api
.request(api.nvrUrl(true))
.done((data) => onReceivedTopLevel(data))
.catch((e) => {
console.error('NVR load exception: ', e);
onReceivedTopLevel(null);
if (e.status == 401) {
loginDialog.dialog('open');
}
});
}
/**
* Class representing the entire application.
*/
export default class NVRApplication {
/**
* Construct the application object.
*/
constructor() {}
/**
* Start the application.
*/
start() {
const nav = $('#nav');
$('#toggle-nav').click(() => {
nav.toggle('slide');
});
loginDialog = $('#login').dialog({
autoOpen: false,
modal: true,
buttons: [
{
id: 'login-submit',
text: 'Login',
click: sendLoginRequest,
},
],
});
loginDialog.pending = false;
loginDialog.find('form').on('submit', function(event) {
event.preventDefault();
sendLoginRequest();
});
sendTopLevelRequest();
}
}

169
ui/src/api.ts Normal file
View File

@ -0,0 +1,169 @@
// 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
/**
* @file Convenience wrapper around the Moonfire NVR API layer.
*
* See <tt>design/api.md</tt> for a description of the API.
*
* The functions here return a Typescript discriminating union of status.
* This seems convenient for ensuring the caller handles all possibilities.
*/
import { Camera, Session } from "./types";
interface FetchSuccess<T> {
status: "success";
response: T;
}
interface FetchAborted {
status: "aborted";
}
export interface FetchError {
status: "error";
message: string;
httpStatus?: number;
}
type FetchResult<T> = FetchSuccess<T> | FetchAborted | FetchError;
async function myfetch(
url: string,
init: RequestInit
): Promise<FetchResult<Response>> {
let response;
try {
response = await fetch(url, init);
} catch (e) {
if (e.name === "AbortError") {
return { status: "aborted" };
} else {
return {
status: "error",
message: `network error: ${e.message}`,
};
}
}
if (!response.ok) {
let text;
try {
text = await response.text();
} catch (e) {
console.warn(
`${url}: ${response.status}: unable to read body: ${e.message}`
);
return {
status: "error",
httpStatus: response.status,
message: `unable to read body: ${e.message}`,
};
}
return {
status: "error",
httpStatus: response.status,
message: text,
};
}
console.debug(`${url}: ${response.status}`);
return {
status: "success",
response,
};
}
/** Fetches an initialization segment. */
export async function init(
hash: string,
init: RequestInit
): Promise<FetchResult<ArrayBuffer>> {
const url = `/api/init/${hash}.mp4`;
const fetchRes = await myfetch(url, init);
if (fetchRes.status !== "success") {
return fetchRes;
}
let body;
try {
body = await fetchRes.response.arrayBuffer();
} catch (e) {
console.warn(`${url}: unable to read body: ${e.message}`);
return {
status: "error",
message: `unable to read body: ${e.message}`,
};
}
return {
status: "success",
response: body,
};
}
async function json<T>(
url: string,
init: RequestInit
): Promise<FetchResult<T>> {
const fetchRes = await myfetch(url, init);
if (fetchRes.status !== "success") {
return fetchRes;
}
let body;
try {
body = await fetchRes.response.json();
} catch (e) {
console.warn(`${url}: unable to read body: ${e.message}`);
return {
status: "error",
message: `unable to read body: ${e.message}`,
};
}
return {
status: "success",
response: body,
};
}
export type 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);
}
export type LoginRequest = {
username: string;
password: string;
};
/** Logs in. */
export async function login(req: LoginRequest, init: RequestInit) {
return await myfetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
...init,
});
}
export type LogoutRequest = {
csrf: string;
};
/** Logs out. */
export async function logout(req: LogoutRequest, init: RequestInit) {
return await myfetch("/api/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
...init,
});
}

View File

@ -1,108 +0,0 @@
@media only screen and (max-width: 720px) {
#nav {
float: none;
display: none;
}
}
body {
font-family: Arial, Helvetica, sans-serif;
}
#top {
width: 100%;
padding-bottom: 2ex;
}
#toggle-nav {
font-size: 1.25em;
cursor: pointer;
}
#nav {
float: left;
margin: 0 0.5em 0.5ex 0;
}
#session {
float: right;
}
#logout {
cursor: pointer;
}
#datetime .ui-datepicker {
width: 100%;
}
#videos {
display: inline-block;
}
#videos tbody:after {
content: "";
display: block;
height: 3ex;
}
table#videos {
border-collapse: collapse;
/*border-spacing: 0.5em 0.5ex;*/
}
tbody tr.name {
font-size: 110%;
background-color: #eee;
}
tbody tr.name th {
border-bottom: 1px solid #666;
}
tbody tr.hdr {
color: #555;
font-size: 90%;
}
tr.r:hover {
background-color: #ddd;
}
tr.r td {
font-size: 80%;
cursor: pointer;
}
tr.r th,
tr.r td {
border: 0;
text-align: right;
padding: 0.5ex 1.5em;
}
fieldset {
font-size: 80%;
}
fieldset legend {
font-size: 120%;
font-weight: bold;
}
#from, #to {
padding-right: 0.75em;
}
#range {
padding: 0.5em 0;
}
.ui-dialog .ui-dialog-content {
overflow: visible; /* remove stupid scroll bars when resizing. */
padding: 0;
}
video {
width: 100%;
height: 100%;
}
@media only screen and (max-width: 768px) {
#nav {
float: none;
display: none;
}
.resolution, .frameRate, .size {
display: none;
}
tr.r th,
tr.r td {
padding: 0.5ex 0.5em;
}
}

View File

@ -1,112 +0,0 @@
<!DOCTYPE html>
<head>
<title>Moonfire NVR</title>
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<div id="top">
<a id="toggle-nav">&#x2630;</a>
<span id="session"></div>
</div>
<div id="nav">
<form action="#">
<fieldset>
<legend>Streams</legend>
<table id="streams"></table>
</fieldset>
<fieldset id="datetime">
<legend>Date &amp; Time Range</legend>
<div id="from">
<legend>From</legend>
<div id="start-date"></div>
<label for="start-time">Time:</label>
<input id="start-time" name="start-time" type="text"
max-length="20"
title="Starting time within the day. Blank for the beginning of
the day. Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is
90,000ths of a second. Timezone is normally left out; it's useful
once a year during the ambiguous times of the &quot;fall
back&quot; hour.">
</div>
<div id="to">
<legend>To</legend>
<div id="range">
<input type="radio" name="end-date-type" id="end-date-same" checked>
<label for="end-date-same">Same Day</label>
<input type="radio" name="end-date-type" id="end-date-other">
<label for="end-date-other">Other Day</label><br/>
</div>
<div id="end-date"></div>
<label for="end-time">Time:</label>
<input id="end-time" name="end-time" type="text" max-length="20"
title="Ending time within the day. Blank for the end of the day.
Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a
second. Timezone is normally left out; it's useful once a year
during the ambiguous times of the &quot;fall back&quot; hour.">
</div>
</fieldset>
<fieldset>
<legend>Recordings Display</legend>
<label for="split">Max Video Duration:</label>
<select name="split" id="split">
<option value="324000000">1 hour</option>
<option value="1296000000">4 hours</option>
<option value="7776000000">24 hours</option>
<option value="infinite">infinite</option>
</select><br>
<input type="checkbox" checked id="trim" name="trim">
<label for="trim" title="Trim each segment of video so that it is fully
contained within the select time range. When this is not selected,
all segments will overlap with the selected time range but may start
and/or end outside it.">Trim Segment Start &amp; End</label><br>
<input type="checkbox" checked id="ts" name="ts">
<label for="ts" title="Include a text track in each .mp4 with the
timestamp at which the video was recorded.">Timestamp Track</label><br>
<label for="timefmt" title="The time format to use when displaying start
and end times in the video segment list. Note this currently doesn't
apply to the start/entry inputs.">Time Format:</label>
<select name="timefmt" id="timefmt">
<option value="MM/DD/YY hh:mm A">US-short</option>
<option value="MM/DD/YYYY hh:mm:ss A">US</option>
<option value="MM/DD/YY HH:mm" selected>Military-short</option>
<option value="MM/DD/YYYY HH:mm:ss">Military</option>
<option value="DD.MM.YY HH:mm">EU-short</option>
<option value="DD-MM-YYYY HH:mm:ss">EU</option>
<option value="YY-MM-DD hh:mm A">ISO-short (12h)</option>
<option value="YY-MM-DD HH:mm">ISO-short (24h)</option>
<option value="YYYY-MM-DD hh:mm:ss A">ISO (12h)</option>
<option value="YYYY-MM-DD HH:mm:ss">ISO (24h)</option>
<option value="YYYY-MM-DD HH:mm:ss">ISO 8601-like (No TZ)</option>
<option value="YYYY-MM-DDTHH:mm:ss">ISO 8601 (No TZ)</option>
<option value="YYYY-MM-DDTHH:mm:ssZ">ISO 8601</option>
<option value="YYYY-MM-DDTHH:mm:ss:FFFFFZ">Internal</option>
</select>
</fieldset>
</form>
</div>
<table id="videos"></table>
<div id="login">
<form>
<fieldset>
<table>
<tr>
<td><label for="login-username">Username:</label></td>
<td><input type="text" id="login-username" name="username"
autocomplete="username"></td>
</tr>
<tr>
<td><label for="login-password">Password:</label></td>
<td><input type="password" id="login-password" name="password"
autocomplete="current-password"></td>
</tr>
<tr>
<td></td>
<td><input type="submit" tabindex="-1" style="position:absolute; top:-1000px"></td>
</tr>
</table>
<p id="login-error"></p>
</fieldset>
</form>
</div>

View File

@ -1,41 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import NVRApplication from './NVRApplication';
import $ from 'jquery';
// On document load, start application
$(function() {
$(document).tooltip();
(new NVRApplication()).start();
});

40
ui/src/index.tsx Normal file
View File

@ -0,0 +1,40 @@
// 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 CssBaseline from "@material-ui/core/CssBaseline";
import {
ThemeProvider,
unstable_createMuiStrictModeTheme as createMuiTheme,
} from "@material-ui/core/styles";
import "fontsource-roboto";
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import ErrorBoundary from "./ErrorBoundary";
import { SnackbarProvider } from "./snackbars";
const theme = createMuiTheme({
palette: {
primary: {
main: "#000000",
},
secondary: {
main: "#e65100",
},
},
});
ReactDOM.render(
<React.StrictMode>
<CssBaseline />
<ThemeProvider theme={theme}>
<ErrorBoundary>
<SnackbarProvider autoHideDuration={5000}>
<App />
</SnackbarProvider>
</ErrorBoundary>
</ThemeProvider>
</React.StrictMode>,
document.getElementById("root")
);

View File

@ -1,194 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import URLBuilder from './support/URLBuilder';
/**
* Class to insulate rest of app from details of Moonfire API.
*
* Can produce URLs for specifc operations, or a request that has been
* started and can have handlers attached.
*/
export default class MoonfireAPI {
/**
* Construct.
*
* The defaults correspond to a standard Moonfire installation on the
* same host that this code runs on.
*
* Requesting relative URLs effectively disregards the host and port options.
*
* @param {String} options.host Host where the API resides
* @param {Number} options.port Port on which the API resides
* @param {[type]} options.relativeUrls True if we should produce relative
* urls
*/
constructor({host = 'localhost', port = 8080, relativeUrls = true} = {}) {
const url = new URL('/api/', `http://${host}`);
url.protocol = 'http:';
url.hostname = host;
url.port = port;
console.log('API: ' + url.origin + url.pathname);
this.builder_ = new URLBuilder(url.origin + url.pathname, relativeUrls);
}
/**
* URL that will cause the state of the NVR to be returned.
*
* @param {Boolean} days True if a return of days with available recordings
* is desired.
* @return {String} Constructed url
*/
nvrUrl(days = true) {
return this.builder_.makeUrl('', {days: days});
}
/**
* URL that will cause the state of a specific recording to be returned.
*
* @param {String} cameraUUID UUID for the camera
* @param {String} streamType "main" or "sub"
* @param {String} start90k Timestamp for beginning of range of interest
* @param {String} end90k Timestamp for end of range of interest
* @param {String} split90k Desired maximum size of segments returned, or
* Infinity for infinite range
* @return {String} Constructed url
*/
recordingsUrl(cameraUUID, streamType, start90k, end90k, split90k = Infinity) {
const query = {
startTime90k: start90k,
endTime90k: end90k,
};
if (split90k != Infinity) {
query.split90k = split90k;
}
return this.builder_.makeUrl(
'cameras/' + cameraUUID + '/' + streamType + '/recordings',
query
);
}
/**
* URL that will playback a video segment.
*
* @param {String} cameraUUID UUID for the camera from whence comes the video
* @param {String} streamType "main" or "sub"
* @param {Recording} recording Recording model object
* @param {Range90k} trimmedRange Range restricting segments
* @param {Boolean} timestampTrack True if track should be timestamped
* @return {String} Constructed url
*/
videoPlayUrl(cameraUUID, streamType, recording, trimmedRange,
timestampTrack = true) {
let sParam = recording.startId;
if (recording.endId !== null) {
sParam += '-' + recording.endId;
}
if (recording.firstUncommitted !== null) {
sParam += '@' + recording.openId; // disambiguate.
}
let rel = '';
if (recording.startTime90k < trimmedRange.startTime90k) {
rel += trimmedRange.startTime90k - recording.startTime90k;
}
rel += '-';
if (recording.endTime90k > trimmedRange.endTime90k) {
rel += trimmedRange.endTime90k - recording.startTime90k;
} else if (recording.growing) {
// View just the portion described by recording.
rel += recording.endTime90k - recording.startTime90k;
}
if (rel !== '-') {
sParam += '.' + rel;
}
console.log('Video query:', {
s: sParam,
ts: timestampTrack,
});
return this.builder_.makeUrl('cameras/' + cameraUUID + '/' + streamType +
'/view.mp4', {
s: sParam,
ts: timestampTrack,
});
}
/**
* Start a new AJAX request with the specified URL.
*
* @param {String} url URL to use
* @return {Request} jQuery request type
*/
request(url) {
return $.ajax(url, {
dataType: 'json',
headers: {
Accept: 'application/json',
},
});
}
/**
* Start a new AJAX request to log in.
*
* @param {String} username
* @param {String} password
* @return {Request}
*/
login(username, password) {
return $.ajax(this.builder_.makeUrl('login'), {
data: JSON.stringify({
username: username,
password: password,
}),
contentType: 'application/json',
method: 'POST',
});
}
/**
* Start a new AJAX request to log out.
*
* @param {String} csrf: the csrf request token as returned in
* <tt>/api/</tt> response JSON.
* @return {Request}
*/
logout(csrf) {
return $.ajax(this.builder_.makeUrl('logout'), {
data: JSON.stringify({
csrf: csrf,
}),
contentType: 'application/json',
method: 'POST',
});
}
}

View File

@ -1,241 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Time90kParser from '../support/Time90kParser';
import TimeStamp90kFormatter from '../support/TimeStamp90kFormatter';
import Range90k from './Range90k';
/**
* Class representing a calendar timestamp range based on 90k units.
*
* A calendar timestamp differs from a Range90k in that a date string
* is involved on each end as well.
*
* The range has a start and end property (via getters) and each has three
* contained properties:
* - dateStr: string for date in ISO8601 format
* - timeStr: string for time in ISO8601 format
* - ts90k: Number for the timestamp in 90k units
*/
export default class CalendarTSRange {
/**
* Construct a range with a given timezone for display purposes.
*
* @param {String} timeZone Desired timezone, e.g. 'America/Los_Angeles'
*/
constructor(timeZone) {
this.start_ = {dateStr: null, timeStr: '', ts90k: null};
this.end_ = {dateStr: null, timeStr: '', ts90k: null};
// Don't need to keep timezone, but need parser and formatter
this.timeFormatter_ = new TimeStamp90kFormatter(timeZone);
this.timeParser_ = new Time90kParser(timeZone);
}
/**
* Determine if a valid start date string is present.
*
* @return {Boolean}
*/
hasStart() {
return this.start.dateStr !== null;
}
/**
* Determine if a valid end date string is present.
*
* @return {Boolean}
*/
hasEnd() {
return this.end.dateStr !== null;
}
/**
* Determine if a valid start and end date string is present.
*
* @return {Boolean}
*/
hasRange() {
return this.hasStart() && this.hasEnd();
}
/**
* Return the range's start component.
*
* @return {object} Object containing dateStr, timeStr, and ts90k components
*/
get start() {
return this.start_;
}
/**
* Return the range's end component.
*
* @return {object} Object containing dateStr, timeStr, and ts90k components
*/
get end() {
return this.end_;
}
/**
* Return the range's start component's ts90k property
*
* @return {object} timestamp in 90k units
*/
get startTime90k() {
return this.start.ts90k;
}
/**
* Return the range's end component's ts90k property
*
* @return {object} timestamp in 90k units
*/
get endTime90k() {
return this.end.ts90k;
}
/**
* Determine if the range has a defined start timestamp in 90k units.
*
* @return {Boolean}
*/
get hasStartTime() {
return this.startTime90k !== null;
}
/**
* Return the calendar range in terms of a range over 90k timestamps.
*
* @return {Range90k} Range object or null if don't have start and end
*/
range90k() {
return this.hasRange() ?
new Range90k(this.startTime90k, this.endTime90k) :
null;
}
/**
* Internal function to update either start or end type range component.
*
* Strings are parsed to check if they are valid. Update only takes place
* if they are. Parsing is in accordance with the installed Time90kParser
* which means:
* - HH:MM:ss:FFFFFZ format, where each component may be empty to indicate 0
* - YYYY-MM-DD format for the date
*
* NOTE: This function potentially modifies the content of the range
* argument. This is on purpose and should reflect the new range values
* upon successful parsing!
*
* @param {object} range A range component
* @param {String} dateStr Date string
* @param {String} timeStr Time string
* @param {Boolean} dateOnlyThenEndOfDay True if one should be added to date
* which is only meaningful if there
* is no time specified here, and also
* not present in the range.
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setRangeTime_(range, dateStr, timeStr, dateOnlyThenEndOfDay) {
const newTs90k = this.timeParser_.parseDateTime90k(
dateStr,
timeStr,
dateOnlyThenEndOfDay
);
if (newTs90k !== null) {
range.dateStr = dateStr;
range.timeStr = timeStr;
range.ts90k = newTs90k;
return newTs90k;
}
return null;
}
/**
* Set start component of range from date and time strings.
*
* Uses _setRangeTime with appropriate dateOnlyThenEndOfDay value.
*
* @param {String} dateStr Date string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setStartDate(dateStr) {
return this.setRangeTime_(this.start_, dateStr, this.start_.timeStr, false);
}
/**
* Set time of start component of range time string.
*
* Uses _setRangeTime with appropriate dateOnlyThenEndOfDay value.
*
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setStartTime(timeStr) {
return this.setRangeTime_(this.start_, this.start_.dateStr, timeStr, false);
}
/**
* Set end component of range from date and time strings.
*
* Uses _setRangeTime with appropriate addOne value.
*
* @param {String} dateStr Date string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setEndDate(dateStr) {
return this.setRangeTime_(this.end_, dateStr, this.end_.timeStr, true);
}
/**
* Set time of end component of range time string.
*
* Uses _setRangeTime with appropriate addOne value.
*
* @param {String} timeStr Time string
* @return {Number} New timestamp if succesfully parsed, null otherwise
*/
setEndTime(timeStr) {
return this.setRangeTime_(this.end_, this.end_.dateStr, timeStr, true);
}
/**
* Format a timestamp in 90k units in the manner consistent with
* what the parser of this module expects.
*
* @param {Number} ts90k Timestamp in 90k units
* @return {String} Formatted string
*/
formatTimeStamp90k(ts90k) {
return this.timeFormatter_.formatTimeStamp90k(ts90k);
}
}

View File

@ -1,71 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Stream from './Stream';
/**
* Camera JSON wrapper.
*/
export default class Camera {
/**
* Construct from JSON.
*
* @param {JSON} cameraJson JSON for single camera.
*/
constructor(cameraJson) {
this.json_ = cameraJson;
this.streams_ = {};
Object.keys(cameraJson.streams).forEach((streamType) => {
this.streams_[streamType] = new Stream(cameraJson.streams[streamType]);
});
}
/** @return {String} */
get uuid() {
return this.json_.uuid;
}
/** @return {String} */
get shortName() {
return this.json_.shortName;
}
/** @return {String} */
get description() {
return this.json_.description;
}
/** @return {Object.<string, Stream>} */
get streams() {
return this.streams_;
}
}

View File

@ -1,73 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* Class to represent ranges of values.
*
* The range has a "low", and "high" value property and is inclusive.
* The "size" property returns the difference between high and low.
*/
export default class Range {
/**
* Create a range.
*
* @param {Number} low Low value (inclusive) in range.
* @param {Number} high High value (inclusive) in range.
*/
constructor(low, high) {
if (high < low) {
console.warn('Warning range swap: ' + low + ' - ' + high);
[low, high] = [high, low];
}
this.low = low;
this.high = high;
}
/**
* Size of the range.
*
* @return {Number} high - low
*/
get size() {
return this.high - this.low;
}
/**
* Determine if value is inside the range.
*
* @param {Number} value Value to test
* @return {Boolean}
*/
isInRange(value) {
return value >= this.low && value <= this.high;
}
}

View File

@ -1,110 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Range from './Range';
/**
* WeakMap that keeps our private data.
*
* @type {WeakMap}
*/
const _range = new WeakMap();
/**
* Class like Range to represent ranges over timestamps in 90k format.
*
* A composed member of the Range class is use for the heavy lifting, while
* this class provides a different interface.
*/
export default class Range90k {
/**
* Create a range.
*
* @param {Number} low Low value (inclusive) in range.
* @param {Number} high High value (inclusive) in range.
*/
constructor(low, high) {
_range.set(this, new Range(low, high));
}
/**
* Return the range's start time.
*
* @return {Number} Number in 90k units
*/
get startTime90k() {
return _range.get(this).low;
}
/**
* Return the range's end time.
*
* @return {Number} Number in 90k units
*/
get endTime90k() {
return _range.get(this).high;
}
/**
* Return the range's duration.
*
* @return {Number} Number in 90k units
*/
get duration90k() {
return _range.get(this).size;
}
/**
* Create a new range by trimming the current range against
* another.
*
* The returned range will lie completely within the provided range.
*
* @param {Range90k} against Range the be used for limits
* @return {Range90k} The trimmed range (always a new object)
*/
trimmed(against) {
return new Range90k(
Math.max(this.startTime90k, against.startTime90k),
Math.min(this.endTime90k, against.endTime90k)
);
}
/**
* Return a copy of this range.
*
* @return {Range90k} A copy of this range object.
*/
clone() {
return new Range90k(this.startTime90k, this.endTime90k);
}
}

View File

@ -1,113 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018-2020 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Range90k from '../models/Range90k';
/**
* Class to encapsulate recording JSON data.
*/
export default class Recording {
/**
* Accept JSON data to be encapsulated
*
* @param {object} recordingJson JSON for a recording
* @param {object} videoSampleEntryJson JSON for a video sample entry
*/
constructor(recordingJson, videoSampleEntryJson) {
/** @const {!number} */
this.startId = recordingJson.startId;
/** @const {?number} */
this.endId = recordingJson.endId !== undefined ? recordingJson.endId : null;
/** @const {!number} */
this.openId = recordingJson.openId;
/** @const {?number} */
this.firstUncommitted = recordingJson.firstUncommitted !== undefined ?
recordingJson.firstUncommitted : null;
/** @const {!boolean} */
this.growing = recordingJson.growing || false;
/** @const {!number} */
this.startTime90k = recordingJson.startTime90k;
/** @const {!number} */
this.endTime90k = recordingJson.endTime90k;
/** @const {!number} */
this.sampleFileBytes = recordingJson.sampleFileBytes;
/** @const {!number} */
this.videoSamples = recordingJson.videoSamples;
/** @const {!number} */
this.videoSampleEntryWidth = videoSampleEntryJson.width;
/** @const {!number} */
this.videoSampleEntryHeight = videoSampleEntryJson.height;
/** @const {!number} */
this.videoSampleEntryPaspHSpacing = videoSampleEntryJson.paspHSpacing;
/** @const {!number} */
this.videoSampleEntryPaspVSpacing = videoSampleEntryJson.paspVSpacing;
}
/**
* Return duration of recording in 90k units.
* @return {Number} Time in units of 90k parts of a second
*/
get duration90k() {
return this.endTime90k - this.startTime90k;
}
/**
* Compute the range of the recording in 90k timestamp units,
* optionally trimmed by another range.
*
* @param {Range90k} trimmedAgainst Optional range to trim against
* @return {Range90k} Resulting range
*/
range90k(trimmedAgainst = null) {
const result = new Range90k(this.startTime90k, this.endTime90k);
return trimmedAgainst ? result.trimmed(trimmedAgainst) : result;
}
/**
* Return duration of recording in seconds.
* @return {Number} Time in units of seconds.
*/
get duration() {
return this.duration90k / 90000;
}
}

View File

@ -1,104 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security stream digital video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Range90k from './Range90k';
/**
* Stream JSON wrapper.
*/
export default class Stream {
/**
* Construct from JSON.
*
* @param {JSON} streamJson JSON for single stream.
*/
constructor(streamJson) {
this.json_ = streamJson;
}
/**
* Get maximimum amount of storage allowed to be used for stream's video
* samples.
*
* @return {Number} Amount in bytes
*/
get retainBytes() {
return this.json_.retainBytes;
}
/**
* Get a Range90K object representing the range encompassing all available
* video samples for the stream.
*
* This range does not mean every second of the range has video!
*
* @return {Range90k} The stream's available recordings range
*/
get range90k() {
return new Range90k(
this.json_.minStartTime90k,
this.json_.maxEndTime90k,
this.json_.totalDuration90k
);
}
/**
* Get the total amount of storage currently taken up by the stream's video
* samples.
*
* @return {Number} Amount in bytes
*/
get totalSampleFileBytes() {
return this.json_.totalSampleFileBytes;
}
/**
* Get the list of the stream's days for which there are video samples.
*
* The result is a Map with dates as keys (in YYYY-MM-DD format) and each
* value is a Range90k object for that day. Here too, the range does not
* mean every second in the range has video, but presence of an entry for
* a day does mean there is at least one (however short) video segment
* available.
*
* @return {Map} Dates are keys, values are Range90K objects.
*/
get days() {
return new Map(
Object.entries(this.json_.days).map(function(t) {
let [k, v] = t;
v = new Range90k(v.startTime90k, v.endTime90k, v.totalDuration90k);
return [k, v];
})
);
}
}

View File

@ -1,101 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import TimeFormatter from './TimeFormatter';
/**
* Formatter for framerates
* @type {Intl} Formatter
*/
const frameRateFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 0,
});
/**
* Formatter for sizes
* @type {Intl} Formatter
*/
const sizeFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 1,
});
/**
* Class encapsulating formatting of recording time ranges.
*/
export default class RecordingFormatter {
/**
* Construct with desired time format and timezone.
*
* @param {String} formatStr Time format string
* @param {String} tz Timezone
*/
constructor(formatStr, tz) {
this.timeFormatter_ = new TimeFormatter(formatStr, tz);
this.singleDateStr_ = null;
}
/**
* Change time format string, preserving timezone.
*
* @param {String} formatStr Time format string
*/
set timeFormat(formatStr) {
this.timeFormatter_ = new TimeFormatter(formatStr, this.timeFormatter_.tz);
}
/**
* Produce an object whose properties are individual pieces of a recording's
* data, formatted for display purposes.
*
* @param {Recording} recording Recording to be formatted
* @param {Range90k} trimRange Optional time range for trimming the
* recording's interval
* @return {Object} Map, keyed by _columnOrder element
*/
format(recording, trimRange = null) {
const duration = recording.duration;
const trimmedRange = recording.range90k(trimRange);
return {
start: this.timeFormatter_.formatTimeStamp90k(trimmedRange.startTime90k),
end: this.timeFormatter_.formatTimeStamp90k(trimmedRange.endTime90k),
resolution:
recording.videoSampleEntryWidth +
'x' +
recording.videoSampleEntryHeight,
frameRate: frameRateFmt.format(recording.videoSamples / duration),
size: sizeFmt.format(recording.sampleFileBytes / 1048576) + ' MB',
rate:
sizeFmt.format(recording.sampleFileBytes / duration * 0.000008) +
' Mbps',
};
}
}

View File

@ -1,134 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import moment from 'moment-timezone';
/**
* Regular expression for parsing time format from timestamps.
*
* These regex captures groups:
* 0: whole match or null if none
* 1: HH:MM portion, or undefined
* 2: :ss portion, or undefined
* 3: FFFFF portion, or undefined
* 4: [+-]hh[:mm] portion, or undefined
*
* @type {RegExp}
*/
const timeRe = new RegExp(
[
'^', // Start
'([0-9]{1,2}:[0-9]{2})', // Capture HH:MM
'(?:(:[0-9]{2})(?::([0-9]{5}))?)?', // Capture [:ss][:FFFFF]
'([+-][0-9]{1,2}:?(?:[0-9]{2})?)?', // Capture [+-][zone]
'$', // End
].join('')
);
/**
* Class to parse time strings that possibly contain fractional
* seconds in 90k units into a Number representation.
*
* The general format:
* Expected timestamps are in this format:
* HH:MM[:ss][:FFFFF][[+-]hh[:mm]]
* where
* HH = hours in one or two digits
* MM = minutes in one or two digits
* ss = seconds in one or two digits
* FFFFF = fractional seconds in 90k units in exactly 5 digits
* hh = hours of timezone offset in one or two digits
* mm = minutes of timezone offset in one or two digits
*
*/
export default class Time90kParser {
/**
* Construct with specific timezone.
*
* @param {String} tz Timezone
*/
constructor(tz) {
self._tz = tz;
}
/**
* Set (another) timezone.
*
* @param {String} tz Timezone
*/
set tz(tz) {
self._tz = tz;
}
/**
* Parses the given date and time string into a valid time90k or null.
*
* The date and time strings must be compatible with the partial ISO-8601
* formats for each, or acceptable to the standard Date object.
*
* If only a date is specified and dateOnlyThenEndOfDay is false, the 00:00
* timestamp for that day is returned. If dateOnlyThenEndOfDay is true, the
* 00:00 of the very next day is returned.
*
* @param {String} dateStr String representing date
* @param {String} timeStr String representing time
* @param {Boolean} dateOnlyThenEndOfDay If only a date was specified and
* this is true, then return time
* for the end of day
* @return {Number} Timestamp in 90k units, or null if parsing failed
*/
parseDateTime90k(dateStr, timeStr, dateOnlyThenEndOfDay) {
// If just date, no special handling needed
if (!timeStr) {
const m = moment.tz(dateStr, self._tz);
if (dateOnlyThenEndOfDay) {
m.add({days: 1});
}
return m.valueOf() * 90;
}
const [match, hhmm, ss, fffff, tz] = timeRe.exec(timeStr) || [];
if (!match) {
return null;
}
const orBlank = (s) => s || '';
const datetimeStr = dateStr + 'T' + hhmm + orBlank(ss) + orBlank(tz);
const m = moment.tz(datetimeStr, self._tz);
if (!m.isValid()) {
return null;
}
const frac = fffff === undefined ? 0 : parseInt(fffff, 10);
return m.valueOf() * 90 + frac;
}
}

View File

@ -1,119 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import moment from 'moment-timezone';
export const defaultTimeFormat = 'YYYY-MM-DD HH:mm:ss';
/**
* Class for formatting timestamps.
*
* There are methods for formatting timestamp in three different unit systems:
* - 90k: The units are multiples of 1/90,000th of a second
* - Sec: The units are multiples of seconds
* - Ms: The units are multiples of milliseconds
*
* The object is initialized with a format string and a timezone. The timezone
* is necessary to format times in that timezone.
*
* The format string is based on those accepted by moment.js with one addition
* detailed in formatTimeStamp90k.
*/
export default class TimeFormatter {
/**
* Construct with specific format string and timezone.
*
* @param {String} formatStr Format specification string
* @param {String} tz Timezone, e.g. "America/Los_Angeles"
*/
constructor(formatStr, tz) {
this.formatStr_ = formatStr || defaultTimeFormat;
this.tz_ = tz;
}
/**
* Get current format string
*
* @return {String} Format specification string
*/
get formatStr() {
return this.formatStr_;
}
/**
* Get current timezone
*
* @return {String} Timezone
*/
get tz() {
return this.tz_;
}
/**
* Produces a human-readable timestamp in 90k units.
*
* The format is anything understood by moment's format function,
* with the addition of one special format indicator consisting of
* five successive Fs. If this pattern is used more than once,
* only the first one will be handled. Subsequent ones will become
* literal strings with five Fs.
*
* Using normal format codes, precision of up the three S (SSS) is
* supported by moment to display decimal seconds. "moment" truncates
* the value passed in to its constructor, effectively truncating
* any fractional values in the timestamp. This function rounds
* to compensate for that, except in the case of the FFFFF pattern,
* where rounding is left out for historical reasons.
*
* FFFFF produces a string indicating how many 90k units are present
* in the sub-second portion of the timestamp. Therefore this is *not*
* a decimal fraction!
*
* @param {Number} ts90k timestamp in 90,000ths of a second resolution
* @return {String} Formatted timestamp
*/
formatTimeStamp90k(ts90k) {
let format = this.formatStr_;
const ms = ts90k / 90.0;
const fracFmt = 'FFFFF';
const fracLoc = format.indexOf(fracFmt);
if (fracLoc != -1) {
const frac = ts90k % 90000;
format =
format.substr(0, fracLoc) +
String(100000 + frac).substr(1) +
format.substr(fracLoc + fracFmt.length);
}
return moment.tz(ms, this.tz_).format(format);
}
}

View File

@ -1,83 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import TimeFormatter from './TimeFormatter';
export const internalTimeFormat = 'YYYY-MM-DDTHH:mm:ss:FFFFFZ';
/**
* Specialized class similar to TimeFormatter but forcing a specific time format
* for internal usage purposes.
*/
export default class TimeStamp90kFormatter {
/**
* Construct from just a timezone specification.
*
* @param {String} tz Timezone
*/
constructor(tz) {
this.formatter_ = new TimeFormatter(internalTimeFormat, tz);
}
/**
* Format a timestamp in 90k units using internal format.
*
* @param {Number} ts90k timestamp in 90,000ths of a second resolution
* @return {String} Formatted timestamp
*/
formatTimeStamp90k(ts90k) {
return this.formatter_.formatTimeStamp90k(ts90k);
}
/**
* Given two timestamp return formatted versions of both, where the second
* one may have been shortened if it falls on the same date as the first one.
*
* @param {Number} ts1 First timestamp in 90k units
* @param {Number} ts2 Secodn timestamp in 90k units
* @return {Array} Array with two elements: [ ts1Formatted, ts2Formatted ]
*/
formatSameDayShortened(ts1, ts2) {
let ts1Formatted = this.formatTimeStamp90k(ts1);
let ts2Formatted = this.formatTimeStamp90k(ts2);
const timePos = this.formatter_.formatStr.indexOf('T');
if (timePos != -1) {
const datePortion = ts1Formatted.substr(0, timePos);
ts1Formatted = datePortion + ' ' + ts1Formatted.substr(timePos + 1);
if (ts2Formatted.startsWith(datePortion)) {
ts2Formatted = ts2Formatted.substr(timePos + 1);
}
}
return [ts1Formatted, ts2Formatted];
}
}

View File

@ -1,81 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* Class to help with URL construction.
*/
export default class URLBuilder {
/**
* Construct builder with a base url.
*
* It is possible to indicate the we only want to extract relative
* urls. In that case, pass a dummy scheme and host.
*
* @param {String} base Base url, including scheme and host
* @param {Boolean} relative True if relative urls desired
*/
constructor(base, relative = true) {
this.baseUrl_ = base;
this.relative_ = relative;
}
/**
* Append query parameters from a map to a URL.
*
* This is cumulative, so if you call this multiple times on the same URL
* the resulting URL will have the combined query parameters and values.
*
* @param {URL} url URL to add query parameters to
* @param {Object} query Object with parameter name/value pairs
* @return {URL} URL where query params have been added
*/
addQuery_(url, query = {}) {
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
return url;
}
/**
* Construct a String url based on an initial path and an optional set
* of query parameters.
*
* The url will be constructed based on the base url, with path appended.
*
* @param {String} path Path to be added to base url
* @param {Object} query Object with query parameters
* @return {String} Formatted url, relative if so configured
*/
makeUrl(path, query = {}) {
const url = new URL(path || '', this.baseUrl_);
this.addQuery_(url, query);
return this.relative_ ? url.pathname + url.search : url.href;
}
}

View File

@ -1,416 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import DatePickerView from './DatePickerView';
import CalendarTSRange from '../models/CalendarTSRange';
import TimeStamp90kFormatter from '../support/TimeStamp90kFormatter';
import Time90kParser from '../support/Time90kParser';
/**
* Find the earliest and latest dates from an array of StreamView
* objects.
*
* Each camera view has a "days" property, whose keys identify days with
* recordings. All such dates are collected and then scanned to find earliest
* and latest dates.
*
* "days" for camera views that are not enabled are ignored.
*
* @param {[Iterable]} streamViews Camera views to look into
* @return {[Set, String, String]} Array with set of all dates, and
* earliest and latest dates
*/
function minMaxDates(streamViews) {
/*
* Produce a set with all dates, across all enabled cameras, that
* have at least one recording available (allDates).
*/
const allDates = new Set(
[].concat(
...streamViews
.filter((v) => v.enabled)
.map((v) => Array.from(v.stream.days.keys()))
)
);
return [
allDates,
...Array.from(allDates.values()).reduce((acc, dateStr) => {
acc[0] = !acc[0] || dateStr < acc[0] ? dateStr : acc[0];
acc[1] = !acc[1] || dateStr > acc[1] ? dateStr : acc[1];
return acc;
}, []),
];
}
/**
* Class to represent a calendar view.
*
* The view consists of:
* - Two date pickers (from and to)
* - A time input box with each date picker (from time, to time)
* - A set of radio buttons to select between same day or not
*
*/
export default class CalendarView {
/**
* Construct the view with UI elements IDs specified.
*
* @param {String} options.fromPickerId Id for from datepicker
* @param {String} options.toPickerId Id for to datepicker
* @param {String} options.isSameDayId Id for same day radio button
* @param {String} options.isOtherDayId Id for other day radio button
* @param {String} options.fromPickerTimeId Id for from time field
* @param {String} options.toPickerTimeId Id for to time field
* @param {[type]} options.timeZone Timezone
*/
constructor({
fromPickerId = 'start-date',
toPickerId = 'end-date',
isSameDayId = 'end-date-same',
isOtherDayId = 'end-date-other',
fromPickerTimeId = 'start-time',
toPickerTimeId = 'end-time',
timeZone = null,
} = {}) {
// Lookup all by id, check and remember
[
this.fromPickerView_,
this.toPickerView_,
this.sameDayElement_,
this.otherDayElement_,
this.startTimeElement_,
this.endTimeElement_,
] = [
fromPickerId,
toPickerId,
isSameDayId,
isOtherDayId,
fromPickerTimeId,
toPickerTimeId,
].map((id) => {
const el = $(`#${id}`);
if (el.length == 0) {
console.log('Warning: Calendar element with id "' + id + '" not found');
}
return el;
});
this.fromPickerView_ = new DatePickerView(this.fromPickerView_);
this.toPickerView_ = new DatePickerView(this.toPickerView_);
this.timeFormatter_ = new TimeStamp90kFormatter(timeZone);
this.timeParser_ = new Time90kParser(timeZone);
this.selectedRange_ = new CalendarTSRange(timeZone);
this.sameDay_ = true; // Start in single day view
this.sameDayElement_.prop('checked', this.sameDay_);
this.otherDayElement_.prop('checked', !this.sameDay_);
this.availableDates_ = null;
this.minDateStr_ = null;
this.maxDateStr_ = null;
this.singleDateStr_ = null;
this.streamViews_ = null;
this.rangeChangedHandler_ = null;
}
/**
* Change timezone.
*
* @param {String} tz New timezone
*/
set tz(tz) {
this.timeParser_.tz = tz;
}
/**
* (Re)configure the datepickers and other calendar range inputs to reflect
* available dates.
*/
configureDatePickers_() {
const dateSet = this.availableDates_;
const minDateStr = this.minDateStr_;
const maxDateStr = this.maxDateStr_;
const fromPickerView = this.fromPickerView_;
const toPickerView = this.toPickerView_;
const beforeShowDay = function(date) {
const year = date.getYear() + 1900;
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const dateStr = [year, month, day].join('-');
return [dateSet.has(dateStr), '', ''];
};
if (this.sameDay_) {
fromPickerView.option({
dateFormat: DatePickerView.datepicker.ISO_8601,
minDate: minDateStr,
maxDate: maxDateStr,
defaultDate: maxDateStr,
onSelect: (dateStr /* , picker */) =>
this.updateRangeDates_(dateStr, dateStr),
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.destroy();
toPickerView.activate(); // Default state, but alive
} else {
fromPickerView.option({
dateFormat: DatePickerView.datepicker.ISO_8601,
minDate: minDateStr,
maxDate: maxDateStr,
defaultDate: maxDateStr,
onSelect: (dateStr /* , picker */) => {
toPickerView.minDate = this.fromDateISO;
this.updateRangeDates_(dateStr, this.toDateISO);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.option({
dateFormat: DatePickerView.datepicker.ISO_8601,
minDate: fromPickerView.dateISO,
maxDate: maxDateStr,
defaultDate: maxDateStr,
onSelect: (dateStr /* , picker */) => {
fromPickerView.maxDate = this.toDateISO;
this.updateRangeDates_(this.fromDateISO, dateStr);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
toPickerView.date = fromPickerView.date;
fromPickerView.maxDate = toPickerView.date;
}
}
/**
* Call an installed handler (if any) to inform that range has changed.
*/
informRangeChange_() {
if (this.rangeChangedHandler_ !== null) {
this.rangeChangedHandler_(this.selectedRange_);
}
}
/**
* Handle a change in the time input of either from or to.
*
* The change requires updating the selected range and then informing
* any listeners that the range has changed (so they can update data).
*
* @param {event} event DOM event that triggered us
* @param {Boolean} isEnd True if this is for end time
*/
pickerTimeChanged_(event, isEnd) {
const pickerElement = event.currentTarget;
const newTimeStr = pickerElement.value;
const selectedRange = this.selectedRange_;
const parsedTS = isEnd ?
selectedRange.setEndTime(newTimeStr) :
selectedRange.setStartTime(newTimeStr);
if (parsedTS === null) {
console.warn('bad time change');
$(pickerElement).addClass('ui-state-error');
return;
}
$(pickerElement).removeClass('ui-state-error');
console.log(
(isEnd ? 'End' : 'Start') +
' time changed to: ' +
parsedTS +
' (' +
this.timeFormatter_.formatTimeStamp90k(parsedTS) +
')'
);
this.informRangeChange_();
}
/**
* Handle a change in the calendar's same/other day settings.
*
* The change means the selected range changes.
*
* @param {Boolean} isSameDay True if the same day radio button was activated
*/
pickerSameDayChanged_(isSameDay) {
// Prevent change if not real change (can happen on initial setup)
if (this.sameDay_ != isSameDay) {
/**
* This is called when the status of the same/other day radio buttons
* changes. We need to determine a new selected range and activiate it.
* Doing so will then also inform the change listener.
*/
const endDate = isSameDay ?
this.selectedRange.start.dateStr :
this.selectedRange.end.dateStr;
this.updateRangeDates_(this.selectedRange.start.dateStr, endDate);
this.sameDay_ = isSameDay;
// Switch between single day and multi-day
this.configureDatePickers_();
}
}
/**
* Reflect a change in start and end date in the calendar view.
*
* The selected range is update, the view is reconfigured as necessary and
* any listeners are informed.
*
* @param {String} startDateStr New starting date
* @param {String} endDateStr New ending date
*/
updateRangeDates_(startDateStr, endDateStr) {
const newRange = this.selectedRange_;
const originalStart = Object.assign({}, newRange.start);
const originalEnd = Object.assign({}, newRange.end);
newRange.setStartDate(startDateStr);
newRange.setEndDate(endDateStr);
const isSameRange = (a, b) => {
return (
a.dateStr == b.dateStr && a.timeStr == b.timeStr && a.ts90k == b.ts90k
);
};
// Do nothing if effectively no change
if (
!isSameRange(newRange.start, originalStart) ||
!isSameRange(newRange.end, originalEnd)
) {
console.log('New range: ' + startDateStr + ' - ' + endDateStr);
this.informRangeChange_();
}
}
/**
* Install event handlers for same/other day radio buttons and the
* time input boxes as both need to result in an update of the calendar
* view.
*/
wireControls_() {
// If same day status changed, update the view
this.sameDayElement_.change(() => this.pickerSameDayChanged_(true));
this.otherDayElement_.change(() => this.pickerSameDayChanged_(false));
// Handle changing of a time input (either from or to)
const handler = (e, isEnd) => {
console.log('Time changed for: ' + (isEnd ? 'end' : 'start'));
this.pickerTimeChanged_(e, isEnd);
};
this.startTimeElement_.change((e) => handler(e, false));
this.endTimeElement_.change((e) => handler(e, true));
}
/**
* (Re)Initialize the calendar based on a collection of camera views.
*
* This is necessary as the camera views ultimately define the limits on
* the date pickers.
*
* @param {Iterable} streamViews Collection of camera views
*/
initializeWith(streamViews) {
this.streamViews_ = streamViews;
[this.availableDates_, this.minDateStr_, this.maxDateStr_] = minMaxDates(
streamViews
);
this.configureDatePickers_();
// Initialize the selected range to the from picker's date
// if we do not have a selected range yet
if (!this.selectedRange.hasStart()) {
const date = this.fromDateISO;
this.updateRangeDates_(date, date);
this.wireControls_();
}
}
/**
* Set a handler to be called when the calendar selection range changes.
*
* The handler will be called with one argument, an object of type
* CalendarTSRange reflecting the current calendar range. It will be called
* whenever that range changes.
*
* @param {Function} handler Function that will be called
*/
set onRangeChange(handler) {
this.rangeChangedHandler_ = handler;
}
/**
* Get the "to" selected date as Date object.
*
* @return {Date} Date value of the "to"date picker
*/
get toDate() {
return this.toPickerView_.date;
}
/**
* Get the "from" selected date as Date object.
*
* @return {Date} Date value of the "from"date picker
*/
get fromDate() {
return this.fromPickerView_.date;
}
/**
* Get the "to" selected date as the date component of an ISO-8601
* formatted string.
*
* @return {String} Date value (YYYY-MM-D) of the "to" date picker
*/
get toDateISO() {
return this.toPickerView_.dateISO;
}
/**
* Get the "from" selected date as the date component of an ISO-8601
* formatted string.
*
* @return {String} Date value (YYYY-MM-D) of the "from" date picker
*/
get fromDateISO() {
return this.fromPickerView_.dateISO;
}
/**
* Get the currently selected range in the calendar view.
*
* @return {CalendarTSRange} Range object
*/
get selectedRange() {
return this.selectedRange_;
}
}

View File

@ -1,303 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import 'jquery-ui/themes/base/core.css';
import 'jquery-ui/themes/base/datepicker.css';
import 'jquery-ui/themes/base/theme.css';
import 'jquery-ui/ui/widgets/datepicker';
/**
* Class to encapsulate datepicker UI widget from jQuery.
*/
export default class DatePickerView {
/**
* Get the singleton datepicker instance.
*
* This is useful for accessing implementation constants, such as
* date formats etc.
*
* @return {jQuery.datepicker} JQuery datepicker instance
*/
static get datepicker() {
return $.datepicker;
}
/**
* Construct wapper an attach to a specified parent DOM node.
*
* @param {Node} parent Note to serve for attachign datepicker
* @param {Object} options Options to pass to datepicker
*/
constructor(parent, options = null) {
this.pickerElement_ = $(parent);
/*
* The widget is somewhat peculiar in that its functionality does
* not exist until it has been called with a settings/options argument
* as the only parameter to the datepicker() function.
* So, unless some are passed in here explicitly, we create a default
* and disabled date picker.
*/
this.initWithOptions_(options);
}
/**
* Initialize the date picker with a set of options.
*
* Attach the datepicker function to its parent and set the specified options.
* If the options are not specified a minimum set of options just enabling the
* datepicker with defaults is used.
*
* @param {Object} options Options for datepicker, or null to just enable
*/
initWithOptions_(options = null) {
this.alive_ = true;
options =
options !== null ?
options :
{
disabled: true,
};
this.pickerElement_.datepicker(options);
}
/**
* Execute a specified datepicker function, passing the arguments.
*
* This function exists to catch the cases where functions are called when
* the picker is not attached (alive).
*
* The first argument to this function should be the name of the desired
* datepicker function, followed by the correct arguments for that function.
*
* @return {Any} Function result
*/
apply_(...args) {
if (!this.alive_) {
console.warn('datepicker not constructed yet [%s]', this.domId);
}
return this.pickerElement_.datepicker(...args);
}
/**
* Activate the datepicker if not already attached.
*
* Basically calls initWithOptions_({disabled: disabled}), but only if not
* already attached. Otherwise just sets the disabled status.
*
* @param {Boolean} disabled True if datepicker needs to be disabled
*/
activate(disabled = true) {
if (this.alive_) {
this.disabled = disabled;
} else {
this.initWithOptions_({
disabled: disabled,
});
}
}
/**
* Get the element the datepicker is attached to.
*
* @return {jQuery} jQuery element
*/
get element() {
return this.pickerElement_;
}
/**
* Set option or options to the datepicker, like the 'option' call with
* various arguments.
*
* Special case is when the datepicker is not (yet) attached. In that case
* we need to initialze the datepicker with the options instead.
*
* @param {object} arg0 First parameter or undefined if not given
* @param {array} args Rest of the parameters (might be empty)
* @return {object} Result of the 'option' call.
*/
option(arg0, ...args) {
/*
* Special case the scenario of calling option setting with just a map of
* settings, when the picker is not alive. That really should translate
* to a constructor call to the datepicker.
*/
if (!this.alive_ && args.length === 0 && typeof arg0 === 'object') {
return this.initWithOptions_(arg0);
}
return this.apply_('option', arg0, ...args);
}
/**
* Return current set of options.
*
* This is special cased here vs. documentation. We need to ask for 'all'.
*
* @return {Object} Datepicker options
*/
options() {
return this.option('all');
}
/**
* Determine whether datepicker is disabled.
*
* @return {Boolean}
*/
get isDisabled() {
return this.apply_('isDisabled');
}
/**
* Set disabled status of picker.
*
* @param {Boolean} disabled True to disable
*/
set disabled(disabled) {
this.option('disabled', disabled);
}
/**
* Get picker's currently selected date.
*
* @return {Date} Selected date
*/
get date() {
return this.apply_('getDate');
}
/**
* Set the datepicker to a specific date.
*
* @param {String|Date} date Desired date as string or Date
*/
set date(date) {
this.apply_('setDate', date);
}
/**
* Get the picker's current date in ISO format.
*
* This will return just the date portion of the ISO-8601 format, or in other
* words: YYYY-MM-DD
*
* @return {String} Date portion of ISO-8601 formatted selected date
*/
get dateISO() {
const year = this.date.getYear() + 1900;
const month = (this.date.getMonth() + 1).toString().padStart(2, '0');
const day = this.date.getDate().toString().padStart(2, '0');
return [year, month, day].join('-');
}
/**
* Get currently set minimum date.
*
* @return {Date} Minimum date
*/
get minDate() {
return this.option('minDate');
}
/**
* Set a new minimum date.
*
* @param {String|Date} value Desired minimum date
*/
set minDate(value) {
this.option('minDate', value);
}
/**
* Get currently set maximum date.
*
* @return {Date} Maximum date
*/
get maxDate() {
return this.option('maxDate');
}
/**
* Set a new maximum date.
*
* @param {String|Date} value Desired maximum date
*/
set maxDate(value) {
this.option('maxDate', value);
}
/**
* Set the picker to open up in a dialog.
*
* This takes a variable number of arguments, like the native dialog function.
*
* @param {varargs} dialogArgs Variable argument list
*/
dialog(...dialogArgs) {
this.apply_('option', dialogArgs);
}
/**
* Make the picker visible.
*/
show() {
this.apply_('show');
}
/**
* Hide the picker.
*/
hide() {
this.apply_('hide');
}
/**
* Refresh the picker.
*/
refresh() {
this.apply_('refresh');
}
/**
* Destroy the picker.
*
* Destroy means detach it from its element and dispose of everything.
* Sets the status in this object to !alive.
*/
destroy() {
this.alive_ = true;
this.apply_('destroy');
this.alive_ = false;
}
}

View File

@ -1,205 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
/**
* Class to control the view of NVR Settings.
*
* These settings/controls include:
* - Max video length
* - Trim segment start/end
* - Time Format
*/
export default class NVRSettingsView {
/**
* Construct based on element ids
*/
constructor({
videoLenId = 'split',
trimCheckId = 'trim',
tsTrackId = 'ts',
timeFmtId = 'timefmt',
} = {}) {
this.ids_ = {videoLenId, trimCheckId, tsTrackId, timeFmtId};
this.videoLength_ = null;
this.videoLengthHandler_ = null;
this.trim_ = null;
this.trimHandler_ = null;
this.timeFmtStr_ = null;
this.timeFmtHandler_ = null;
this.tsTrack_ = null;
this.tsTrackHandler_ = null;
this.wireControls_();
}
/**
* Find selected option in <select> and return value, or first option's value.
*
* The first option's value is returned if no option is selected.
*
* @param {jQuery} selectEl jQuery element for the <select>
* @return {String} Value of the selected/first option
*/
findSelectedOrFirst_(selectEl) {
let value = selectEl.find(':selected').val();
if (!value) {
value = selectEl.find('option:first-child').val();
}
return value;
}
/**
* Wire up all controls and handlers.
*
*/
wireControls_() {
const videoLengthEl = $(`#${this.ids_.videoLenId}`);
const normalize = (v) => v == 'infinite' ? Infinity : Number(v);
this.videoLength_ = normalize(this.findSelectedOrFirst_(videoLengthEl));
videoLengthEl.change((e) => {
this.videoLength_ = normalize(e.currentTarget.value);
if (this.videoLengthHandler_) {
this.videoLengthHandler_(this.videoLength_);
}
});
const trimEl = $(`#${this.ids_.trimCheckId}`);
this.trim_ = trimEl.is(':checked');
trimEl.change((e) => {
this.trim_ = e.currentTarget.checked;
if (this.trimHandler_) {
this.trimHandler_(this.trim_);
}
});
const timeFmtEl = $(`#${this.ids_.timeFmtId}`);
this.timeFmtStr_ = this.findSelectedOrFirst_(timeFmtEl);
timeFmtEl.change((e) => {
this.timeFmtStr_ = e.target.value;
if (this.timeFmtHandler_) {
this.timeFmtHandler_(this.timeFmtStr_);
}
});
const trackEl = $(`#${this.ids_.tsTrackId}`);
this.tsTrack_ = trackEl.is(':checked');
trackEl.change((e) => {
this.tsTrack_ = e.target.checked;
if (this.tsTrackHandler_) {
this.tsTrackHandler_(this.tsTrack_);
}
});
}
/**
* Get currently selected video length.
*
* @return {Number} Video length value
*/
get videoLength() {
return this.videoLength_;
}
/**
* Get currently selected time format string.
*
* @return {String} Format string
*/
get timeFormatString() {
return this.timeFmtStr_;
}
/**
* Get currently selected trim setting.
*
* @return {Boolean}
*/
get trim() {
return this.trim_;
}
/**
* Determine value of timestamp tracking option
*
* @return {Boolean}
*/
get timeStampTrack() {
return this.tsTrack_;
}
/**
* Set a handler to be called when the time format string changes.
*
* The handler will be called with one argument: the new format string.
*
* @param {Function} handler Format change handler
*/
set onTimeFormatChange(handler) {
this.timeFmtHandler_ = handler;
}
/**
* Set a handler to be called when video length popup changes.
*
* The handler will be called with one argument: the new video length.
*
* @param {Function} handler Video Length change handler
*/
set onVideoLengthChange(handler) {
this.videoLengthHandler_ = handler;
}
/**
* Set a handler to be called when video trim checkbox changes.
*
* The handler will be called with one argument: the new trim value (Boolean).
*
* @param {Function} handler Trim change handler
*/
set onTrimChange(handler) {
this.trimHandler_ = handler;
}
/**
* Set a handler to be called when video timestamp tracking checkbox changes.
*
* The handler will be called with one argument: the new tsTrack value
* (Boolean).
*
* @param {Function} handler Timestamp track change handler
*/
set onTimeStampTrackChange(handler) {
this.tsTrackHandler_ = handler;
}
}

View File

@ -1,288 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import Recording from '../models/Recording';
/**
* Desired column order in recordings table.
*
* The column names must correspond to the propertu names in the JSON
* representation of recordings.
*
* @todo This should be decoupled!
*
* @type {Array} Array of column names
*/
const _columnOrder = [
'start',
'end',
'resolution',
'frameRate',
'size',
'rate',
];
/**
* Labels for columns.
*/
const _columnLabels = {
start: 'Start',
end: 'End',
resolution: 'Resolution',
frameRate: 'FPS',
size: 'Storage',
rate: 'BitRate',
};
/**
* Class to encapsulate a view of a list of recordings from a single camera.
*/
export default class RecordingsView {
/**
* Construct display from camera data and use supplied formatter.
*
* @param {Camera} camera camera object (immutable)
* @param {String} streamType "main" or "sub"
* @param {RecordingFormatter} recordingFormatter Desired formatter
* @param {Boolean} trimmed True if the display should include trimmed ranges
* @param {jQuery} parent Parent to which new DOM is attached, or null
*/
constructor(camera, streamType, recordingFormatter, trimmed = false,
parent = null) {
this.cameraName_ = camera.shortName;
this.cameraRange_ = camera.range90k;
this.formatter_ = recordingFormatter;
const id = `tab-${camera.uuid}-${streamType}`;
this.element_ = this.createElement_(id, camera.shortName, streamType);
this.trimmed_ = trimmed;
this.recordings_ = null;
this.recordingsRange_ = null;
this.clickHandler_ = null;
if (parent) {
parent.append(this.element_);
}
this.timeoutId_ = null;
}
/**
* Create DOM for the recording.
*
* @param {String} id DOM id for the main element
* @param {String} cameraName Name of the corresponding camera
* @param {String} streamType "main" or "sub"
* @return {jQuery} Partial DOM as jQuery object
*/
createElement_(id, cameraName, streamType) {
const tab = $('<tbody>').attr('id', id);
tab.append(
$('<tr class="name">').append($('<th colspan=6/>')
.text(cameraName + ' ' + streamType)),
$('<tr class="hdr">').append(
$(
_columnOrder
.map((name) =>
`<th class="${name}">${_columnLabels[name]}</th>`)
.join('')
)
),
$('</tr>'),
$('<tr class="loading"><td colspan=6>loading...</td></tr>').hide()
);
return tab;
}
/**
* Update display for new recording values.
*
* Each existing row is reformatted.
*
* @param {Array} newRecordings
* @param {Boolean} trimmed True if timestamps should be trimmed
*/
updateRecordings_() {
const trimRange = this.trimmed_ ? this.recordingsRange : null;
const recordings = this.recordings_;
this.element_.children('tr.r').each((rowIndex, row) => {
const values = this.formatter_.format(recordings[rowIndex], trimRange);
$(row)
.children('td')
.each((i, e) => $(e).text(values[_columnOrder[i]]));
});
}
/**
* Get the currently remembered recordings range for this view.
*
* This range corresponds to what was in the data time range selector UI
* at the time the data for this view was selected. The value is remembered
* purely for trimming purposes.
*
* @return {Range90k} Currently remembered range
*/
get recordingsRange() {
return this.recordingsRange_ ? this.recordingsRange_.clone() : null;
}
/**
* Set the recordings range for this view.
*
* @param {Range90k} range90k Range to remember
*/
set recordingsRange(range90k) {
this.recordingsRange_ = range90k ? range90k.clone() : null;
}
/**
* Get whether time ranges in the recording list are being trimmed.
*
* @return {Boolean}
*/
get trimmed() {
return this.trimmed_;
}
/**
* Set whether recording time ranges should be trimmed.
*
* @param {Boolean} value True if trimming desired
*/
set trimmed(value) {
if (value != this.trimmed_) {
this.trimmed_ = value;
this.updateRecordings_();
}
}
/**
* Show or hide the display in the DOM.
*
* @param {Boolean} show True for show, false for hide
*/
set show(show) {
const sel = this.element_;
if (show) {
sel.show();
} else {
sel.hide();
}
}
/**
* Set whether loading indicator should be shown or not.
*
* @param {Boolean} show True if indicator should be showing
*/
set showLoading(show) {
const loading = $('tr.loading', this.element_);
if (show) {
loading.show();
} else {
if (this.timeoutId_) {
clearTimeout(this.timeoutId_);
this.timeoutId_ = null;
}
loading.hide();
}
}
/**
* Show the loading indicated after a delay, unless the timer has been
* cleared already.
*
* @param {Number} timeOutMs Delay (in ms) before indicator should appear
*/
delayedShowLoading(timeOutMs) {
this.timeoutId_ = setTimeout(() => (this.showLoading = true), timeOutMs);
}
/**
* Set a new time format string.
*
* This string is passed on to the formatter and the recordings list
* is updated (using the formatter).
*
* @param {String} formatStr Formatting string
*/
set timeFormat(formatStr) {
// Change the formatter and update recordings (view)
this.formatter_.timeFormat = formatStr;
this.updateRecordings_();
}
/**
* Set a handler to receive clicks on a recording.
*
* The handler will be called with one argument: a recording model.
*
* @param {Function} h Handler to be called.
*/
set onRecordingClicked(h) {
this.clickHandler_ = h;
}
/**
* Set the list of recordings from JSON data.
*
* The data is expected to be an array with recording objects.
*
* @param {object} recordingsJSON JSON data (object)
*/
set recordingsJSON(recordingsJSON) {
this.showLoading = false;
// Store as model objects
this.recordings_ = recordingsJSON.recordings.map(function(r) {
const vse = recordingsJSON.videoSampleEntries[r.videoSampleEntryId];
return new Recording(r, vse);
});
const tbody = this.element_;
// Remove existing rows, replace with new ones
$('tr.r', tbody).remove();
this.recordings_.forEach((r) => {
const row = $('<tr class="r" />');
row.append(_columnOrder.map((c) => $(`<td class="${c}"/>`)));
row.on('click', () => {
console.log('Video clicked');
if (this.clickHandler_ !== null) {
console.log('Video clicked handler call');
this.clickHandler_(r);
}
});
tbody.append(row);
});
// Cause formatting and date to be put in the rows
this.updateRecordings_();
}
}

View File

@ -1,101 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
const allStreamTypes = ['main', 'sub'];
/**
* View for selecting the enabled streams.
*
* This displays a table with a camera per row and stream type per column.
* It propagates the enabled status on to the stream view. It also calls
* the optional onChange handler on any change.
*/
export default class StreamSelectorView {
/**
* @param {Array} cameras An element for each camera with
* - camera: a {Camera}
* - streamViews: a map of stream type to {StreamView}
* @param {jQuery} parent jQuery parent element to append to
*/
constructor(cameras, parent) {
this.cameras_ = cameras;
if (cameras.length !== 0) {
// Add a header row.
const hdr = $('<tr/>').append($('<th/>'));
for (const streamType of allStreamTypes) {
hdr.append($('<th/>').text(streamType));
}
parent.append(hdr);
}
this.cameras_.forEach((c) => {
const row = $('<tr/>').append($('<td>').text(c.camera.shortName));
let firstStreamType = true;
for (const streamType of allStreamTypes) {
const streamView = c.streamViews[streamType];
if (streamView === undefined) {
row.append('<td/>');
} else {
const id = 'cam-' + c.camera.uuid + '-' + streamType;
const cb = $('<input type="checkbox">')
.attr('name', id)
.attr('id', id);
// Only the first stream type for each camera should be checked
// initially.
cb.prop('checked', firstStreamType);
streamView.enabled = firstStreamType;
firstStreamType = false;
cb.change((e) => {
streamView.enabled = e.target.checked;
if (this.onChangeHandler_) {
this.onChangeHandler_();
}
});
row.append($('<td/>').append(cb));
}
}
parent.append(row);
});
this.onChangeHandler_ = null;
}
/** @param {function()} handler a handler to run after toggling a stream */
set onChange(handler) {
this.onChangeHandler_ = handler;
}
}

View File

@ -1,171 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import RecordingsView from './RecordingsView';
/**
* Stream view: a list of available recording segments for playback.
*/
export default class StreamView {
/**
* @param {Camera} cameraModel Model object for camera
* @param {String} streamType "main" or "sub"
* @param {[type]} recordingFormatter Formatter to be used by recordings
* @param {[type]} trimmed True if rec. ranges should be trimmed
* @param {[type]} recordingsParent Parent element to attach to or null)
*/
constructor(
cameraModel,
streamType,
recordingFormatter,
trimmed,
recordingsParent = null
) {
this.camera = cameraModel;
this.streamType = streamType;
this.stream = cameraModel.streams[streamType];
this.recordingsView = new RecordingsView(
this.camera,
this.streamType,
recordingFormatter,
trimmed,
recordingsParent
);
this.enabled_ = true;
this.recordingsUrl = null;
this.recordingsReq = null;
}
/**
* Get whether the view is enabled or not.
*
* @return {Boolean}
*/
get enabled() {
return this.enabled_;
}
/**
* Change enabled state of the view.
*
* @param {Boolean} enabled Whether view should be enabled
*/
set enabled(enabled) {
this.enabled_ = enabled;
this.recordingsView.show = enabled;
console.log('Stream %s-%s %s', this.camera.shortName, this.streamType,
this.enabled ? 'enabled' : 'disabled');
}
/**
* Get the currently remembered recordings range for this camera.
*
* This is just passed on to the recordings view.
*
* @return {Range90k} Currently remembered range
*/
get recordingsRange() {
return this.recordingsView.recordingsRange;
}
/**
* Set the recordings range for this view.
*
* This is just passed on to the recordings view.
*
* @param {Range90k} range90k Range to remember
*/
set recordingsRange(range90k) {
this.recordingsView.recordingsRange = range90k;
}
/**
* Set whether loading indicator should be shown or not.
*
* This indicator is really on the recordings list.
*
* @param {Boolean} show True if indicator should be showing
*/
set showLoading(show) {
this.recordingsView.showLoading = show;
}
/**
* Show the loading indicated after a delay, unless the timer has been
* cleared already.
*
* @param {Number} timeOutMs Delay (in ms) before indicator should appear
*/
delayedShowLoading(timeOutMs) {
this.recordingsView.delayedShowLoading(timeOutMs);
}
/**
* Set new recordings from JSON data.
*
* @param {Object} dataJSON JSON data (array)
*/
set recordingsJSON(dataJSON) {
this.recordingsView.recordingsJSON = dataJSON;
}
/**
* Set a new time format string for the recordings list.
*
* @param {String} formatStr Formatting string
*/
set timeFormat(formatStr) {
this.recordingsView.timeFormat = formatStr;
}
/**
* Set the trimming option of the cameraview as desired.
*
* This is really just passed on to the recordings view.
*
* @param {Boolean} enabled True if trimming should be enabled
*/
set trimmed(enabled) {
this.recordingsView.trimmed = enabled;
}
/**
* Set a handler for clicks on a recording.
*
* The handler will be called with one argument, the recording model.
*
* @param {Function} h Handler function
*/
set onRecordingClicked(h) {
this.recordingsView.onRecordingClicked = h;
}
}

View File

@ -1,115 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery';
import 'jquery-ui/themes/base/button.css';
import 'jquery-ui/themes/base/core.css';
import 'jquery-ui/themes/base/dialog.css';
import 'jquery-ui/themes/base/theme.css';
// This not needed for pure dialog, but we want it resizable
import 'jquery-ui/themes/base/resizable.css';
// Get dialog ui widget
import 'jquery-ui/ui/widgets/dialog';
/**
* Class to implement a simple jQuery dialog based video player.
*/
export default class VideoDialogView {
/**
* Construct the player.
*
* This does not attach the player to the DOM anywhere! In fact, construction
* of the necessary video element is delayed until an attach is requested.
* Since the close of the video removes all traces of it in the DOM, this
* approach allows repeated use by calling attach again!
*/
constructor() {}
/**
* Attach the player to the specified DOM element.
*
* @param {Node} domElement DOM element to attach to
* @return {VideoDialogView} Returns "this" for chaining.
*/
attach(domElement) {
this.videoElement_ = $('<video controls preload="auto" autoplay="true" />');
this.dialogElement_ = $('<div class="playdialog" />').append(
this.videoElement_
);
$(domElement).append(this.dialogElement_);
return this;
}
/**
* Show the player, and start playing the given url.
*
* @param {String} title Title of the video player
* @param {Number} width Width of the player
* @param {String} url URL for source media
* @return {VideoDialogView} Returns "this" for chaining.
*/
play(title, width, url) {
const videoDomElement = this.videoElement_[0];
this.dialogElement_.dialog({
title: title,
width: width,
close: () => {
videoDomElement.pause();
videoDomElement.src = ''; // Remove current source to stop loading
this.videoElement_ = null;
this.dialogElement_.remove();
this.dialogElement_ = null;
},
});
// Now that dialog is up, set the src so video starts
console.log('Video url: ' + url);
this.videoElement_.attr('src', url);
// On narrow displays (as defined by index.css), play videos in
// full-screen mode. When the user exits full-screen mode, close the
// dialog.
const narrowWindow = $('#nav').css('float') == 'none';
if (narrowWindow) {
console.log('Narrow window; starting video in full-screen mode.');
videoDomElement.requestFullscreen();
videoDomElement.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement !== videoDomElement) {
console.log('Closing video because user exited full-screen mode.');
this.dialogElement_.dialog('close');
}
});
}
return this;
}
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

5
ui/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
// 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
/// <reference types="react-scripts" />

37
ui/src/setupProxy.js Normal file
View File

@ -0,0 +1,37 @@
// 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
// https://create-react-app.dev/docs/proxying-api-requests-in-development/
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = (app) => {
app.use(
"/api",
createProxyMiddleware({
target: process.env.PROXY_TARGET ?? "http://localhost:8080/",
ws: true,
changeOrigin: true,
// If the backing host is https, Moonfire NVR will set a 'secure'
// attribute on cookie responses, so that the browser will only send
// them over https connections. This is a good security practice, but
// it means a non-https development proxy server won't work. Strip out
// this attribute in the proxy with code from here:
// https://github.com/chimurai/http-proxy-middleware/issues/169#issuecomment-575027907
// See also discussion in guide/developing-ui.md.
onProxyRes: (proxyRes, req, res) => {
const sc = proxyRes.headers["set-cookie"];
if (Array.isArray(sc)) {
proxyRes.headers["set-cookie"] = sc.map((sc) => {
return sc
.split(";")
.filter((v) => v.trim().toLowerCase() !== "secure")
.join("; ");
});
}
},
})
);
};

9
ui/src/setupTests.ts Normal file
View File

@ -0,0 +1,9 @@
// 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
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

60
ui/src/snackbars.test.tsx Normal file
View File

@ -0,0 +1,60 @@
// 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 { render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { SnackbarProvider, useSnackbars } from "./snackbars";
// Mock out timers.
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test("notifications that time out", async () => {
function AddSnackbar() {
const snackbars = useSnackbars();
useEffect(() => {
snackbars.enqueue({ message: "message A" });
snackbars.enqueue({ message: "message B" });
});
return null;
}
render(
<SnackbarProvider autoHideDuration={5000}>
<AddSnackbar />
</SnackbarProvider>
);
// message A should be present immediately.
expect(screen.queryByText(/message A/)).toBeInTheDocument();
expect(screen.queryByText(/message B/)).not.toBeInTheDocument();
// ...then start to close...
jest.advanceTimersByTime(5000);
expect(screen.queryByText(/message A/)).toBeInTheDocument();
expect(screen.queryByText(/message B/)).not.toBeInTheDocument();
// ...then it should close and message B should open...
jest.runOnlyPendingTimers();
expect(screen.queryByText(/message A/)).not.toBeInTheDocument();
expect(screen.queryByText(/message B/)).toBeInTheDocument();
// ...then message B should start to close...
jest.advanceTimersByTime(5000);
expect(screen.queryByText(/message A/)).not.toBeInTheDocument();
expect(screen.queryByText(/message B/)).toBeInTheDocument();
// ...then message B should fully close.
jest.runOnlyPendingTimers();
expect(screen.queryByText(/message A/)).not.toBeInTheDocument();
expect(screen.queryByText(/message B/)).not.toBeInTheDocument();
});
// TODO: test dismiss.
// TODO: test that context never changes.
// TODO: test drop-on-enqueue.
// TODO: test drop-after-enqueue, with manual and automatic keys.

188
ui/src/snackbars.tsx Normal file
View File

@ -0,0 +1,188 @@
// 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
/**
* App-wide provider for imperative snackbar.
*
* I chose not to use the popular
* <a href="https://www.npmjs.com/package/notistack">notistack</a> because it
* doesn't seem oriented for complying with the <a
* href="https://material.io/components/snackbars">material.io spec</a>.
* Besides supporting non-compliant behaviors (eg <tt>maxSnack</tt> > 1</tt>),
* it doesn't actually enqueue notifications. Newer ones replace older ones.
*
* This isn't as flexible as <tt>notistack</tt> because I don't need that
* flexibility (yet).
*/
import IconButton from "@material-ui/core/IconButton";
import Snackbar, {
SnackbarCloseReason,
SnackbarProps,
} from "@material-ui/core/Snackbar";
import CloseIcon from "@material-ui/icons/Close";
import React, { useContext } from "react";
interface SnackbarProviderProps {
/**
* The autohide duration to use if none is provided to <tt>enqueue</tt>.
*/
autoHideDuration: number;
children: React.ReactNode;
}
export interface MySnackbarProps
extends Omit<
SnackbarProps,
| "key"
| "anchorOrigin"
| "open"
| "handleClosed"
| "handleExited"
| "actions"
> {
key?: React.Key;
}
type MySnackbarPropsWithRequiredKey = Omit<MySnackbarProps, "key"> &
Required<Pick<MySnackbarProps, "key">>;
interface Enqueued extends MySnackbarPropsWithRequiredKey {
open: boolean;
}
/**
* Imperative interface to enqueue and close app-wide snackbars.
* These methods should be called from effects (not directly from render).
*/
export interface Snackbars {
/**
* Enqueues a snackbar.
*
* @param snackbar
* The snackbar to add. The only required property is <tt>message</tt>. If
* <tt>key</tt> is present, it will close any message with the same key
* immediately, as well as be returned so it can be passed to close again
* later. Note that currently several properties are used internally and
* can't be specified, including <tt>actions</tt>.
* @return A key that can be passed to close: the caller-specified key if
* possible, or an internally generated key otherwise.
*/
enqueue: (snackbar: MySnackbarProps) => React.Key;
/**
* Closes a snackbar if present.
*
* If it is currently visible, it will be allowed to gracefully close.
* Otherwise it's removed from the queue.
*/
close: (key: React.Key) => void;
}
interface State {
queue: Enqueued[];
}
const ctx = React.createContext<Snackbars | null>(null);
/**
* Provides a <tt>Snackbars</tt> instance for use by <tt>useSnackbars</tt>.
*/
// This is a class because I want to guarantee the context value never changes,
// and I couldn't figure out a way to do that with hooks.
export class SnackbarProvider
extends React.Component<SnackbarProviderProps, State>
implements Snackbars {
constructor(props: SnackbarProviderProps) {
super(props);
this.state = { queue: [] };
}
autoKeySeq = 0;
enqueue(snackbar: MySnackbarProps): React.Key {
let key =
snackbar.key === undefined ? `auto-${this.autoKeySeq++}` : snackbar.key;
// TODO: filter existing.
this.setState((state) => ({
queue: [...state.queue, { key, open: true, ...snackbar }],
}));
return key;
}
handleCloseSnackbar = (
key: React.Key,
event: React.SyntheticEvent<any>,
reason: SnackbarCloseReason
) => {
if (reason === "clickaway") return;
this.setState((state) => {
const snack = state.queue[0];
if (snack?.key !== key) {
console.warn(`Active snack is ${snack?.key}; expected ${key}`);
return null; // no change.
}
const newSnack: Enqueued = { ...snack, open: false };
return { queue: [newSnack, ...state.queue.slice(1)] };
});
};
handleSnackbarExited = (key: React.Key) => {
this.setState((state) => ({ queue: state.queue.slice(1) }));
};
close(key: React.Key): void {
this.setState((state) => {
// If this is the active snackbar, let it close gracefully, as in
// handleCloseSnackbar.
if (state.queue[0]?.key === key) {
const newSnack: Enqueued = { ...state.queue[0], open: false };
return { queue: [newSnack, ...state.queue.slice(1)] };
}
// Otherwise, remove it before it shows up at all.
return { queue: state.queue.filter((e: Enqueued) => e.key !== key) };
});
}
render(): JSX.Element {
const first = this.state.queue[0];
const snackbars: Snackbars = this;
return (
<ctx.Provider value={snackbars}>
{this.props.children}
{first === undefined ? null : (
<Snackbar
{...first}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
autoHideDuration={
first.autoHideDuration ?? this.props.autoHideDuration
}
onClose={(event, reason) =>
this.handleCloseSnackbar(first.key, event, reason)
}
onExited={() => this.handleSnackbarExited(first.key)}
action={
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => this.close(first.key)}
>
<CloseIcon fontSize="small" />
</IconButton>
}
/>
)}
</ctx.Provider>
);
}
}
/** Returns a <tt>Snackbars</tt> from context. */
export function useSnackbars(): Snackbars {
return useContext(ctx)!;
}

20
ui/src/testutil.tsx Normal file
View File

@ -0,0 +1,20 @@
// 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 { render } from "@testing-library/react";
import { SnackbarProvider } from "./snackbars";
export function renderWithCtx(
children: React.ReactElement
): Pick<ReturnType<typeof render>, "rerender"> {
function wrapped(children: React.ReactElement): React.ReactElement {
return (
<SnackbarProvider autoHideDuration={5000}>{children}</SnackbarProvider>
);
}
const { rerender } = render(wrapped(children));
return {
rerender: (children: React.ReactElement) => rerender(wrapped(children)),
};
}

13
ui/src/types.tsx Normal file
View File

@ -0,0 +1,13 @@
// 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
export interface Session {
username: string;
csrf: string;
}
export interface Camera {
uuid: string;
shortName: string;
}

26
ui/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@ -1,108 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018-2020 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const path = require('path');
const webpack = require('webpack');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
nvr: './src/index.js',
},
output: {
filename: '[name].[chunkhash].js',
path: path.resolve('./dist/'),
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
query: {
presets: [
['@babel/preset-env', {
targets: {
esmodules: true,
},
modules: false
}]
],
},
exclude: /(node_modules|bower_components)/,
include: [path.resolve('./src')],
},
{
test: /\.png$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[contenthash].[ext]',
},
},
],
},
{
// Load css and then in-line in head
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new webpack.IgnorePlugin(/\.\/locale$/),
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment\/moment\.js$/,
'./min/moment.min.js'
),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment-timezone\/index\.js$/,
'./builds/moment-timezone-with-data-2012-2022.min.js'
),
new FaviconsWebpackPlugin({
logo: './src/favicon.svg',
mode: 'webapp',
devMode: 'light',
prefix: 'favicons-[hash]/',
favicons: {
coast: false,
windows: false,
yandex: false,
},
}),
],
};

View File

@ -1,89 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./base.config.js');
module.exports = merge(baseConfig, {
stats: {
warnings: true,
},
devtool: 'inline-source-map',
mode: 'development',
optimization: {
minimize: false,
namedChunks: true,
},
output: {
filename: '[name].[hash].js',
},
devServer: {
inline: true,
port: process.env.MOONFIRE_DEV_PORT || 3000,
host: process.env.MOONFIRE_DEV_HOST,
hot: true,
clientLogLevel: 'info',
proxy: {
'/api': {
target: process.env.MOONFIRE_URL || 'http://localhost:8080/',
// The live stream URLs require WebSockets.
ws: true,
// Change the Host: header so the name-based virtual hosts work
// properly.
changeOrigin: true,
// If the backing host is https, Moonfire NVR will set a 'secure'
// attribute on cookie responses, so that the browser will only send
// them over https connections. This is a good security practice, but
// it means a non-https development proxy server won't work. Strip out
// this attribute in the proxy with code from here:
// https://github.com/chimurai/http-proxy-middleware/issues/169#issuecomment-575027907
// See also discussion in guide/developing-ui.md.
onProxyRes: (proxyRes, req, res) => {
const sc = proxyRes.headers['set-cookie'];
if (Array.isArray(sc)) {
proxyRes.headers['set-cookie'] = sc.map(sc => {
return sc.split(';')
.filter(v => v.trim().toLowerCase() !== 'secure')
.join('; ')
});
}
},
},
},
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});

View File

@ -1,99 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2018 The Moonfire NVR Authors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const webpack = require('webpack');
const CompressionPlugin = require('compression-webpack-plugin');
const baseConfig = require('./base.config.js');
const merge = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = (env, args) => {
return merge(baseConfig, {
devtool: 'source-map',
mode: 'production',
module: {
rules: [
{
test: /\.html$/,
loader: 'html-loader',
query: {
minimize: true,
},
},
],
},
optimization: {
minimize: true,
splitChunks: {
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 4,
cacheGroups: {
'default': {
minChunks: 2,
priority: -20,
},
'jquery-ui': {
test: /[\\/]node_modules[\\/]jquery-ui[\\/]/,
name: 'jquery-ui',
chunks: 'all',
priority: -5,
},
'jquery': {
test: /[\\/]node_modules[\\/]jquery[\\/]/,
name: 'jquery',
chunks: 'all',
priority: -5,
},
'vendors': {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: -10,
},
},
},
},
plugins: [
new CleanWebpackPlugin(),
new CompressionPlugin({
filename: '[path].gz[query]',
algorithm: 'gzip',
test: /\.js$|\.css$|\.html$/,
threshold: 10240,
minRatio: 0.8,
}),
],
});
};