Compare commits

...

65 Commits

Author SHA1 Message Date
Scott Lamb 0422593ec6 ui list view: tool tip to see why recording ended
Users are often puzzled why there are short recordings. Previously
the only way to see this was to examine Moonfire's logs. This should
be a much better experience to find it right in the UI where you're
wondering, and without the potential the logs are gone.

Fixes #302
2024-06-01 07:46:11 -07:00
Scott Lamb adf73a2da1 .cargo/config -> .cargo/config.toml
This addresses a deprecation warning. The latter filename has been
supported since Rust 1.39, well under our MSRV.
2024-06-01 06:01:27 -07:00
Scott Lamb c20c644747 fix some Rust 1.78.0 clippy warnings 2024-06-01 06:00:28 -07:00
Scott Lamb 6c227ec0f5 improve build error on `git` failure
This tells folks what to do if they are trying to build from a release
source archive, as in the following discussion:
<https://github.com/scottlamb/moonfire-nvr/discussions/318>
2024-06-01 05:38:04 -07:00
Scott Lamb e6c7b800fe tolerate bad sps/pps, continued 2024-05-30 18:19:58 -07:00
Scott Lamb 1ae61b4c64 fix some warnings 2024-05-30 18:19:16 -07:00
Scott Lamb eb97e618fd prep 0.7.15 w/ Retina updates 2024-05-26 10:25:13 -07:00
Scott Lamb 93a9ad9af3 attempt at iPhone support (#121) 2024-04-16 21:20:07 -07:00
Scott Lamb 9acb095a5d prep v0.7.14 2024-04-16 21:07:34 -07:00
Scott Lamb 8b5f2b4b0d work on Firefox!
Fixes #286.

I dug into Firefox code a bit to understand this but got a lost. But
I guess there are different policies for what's accessible via
`video.src = ""` than for `<video src="">`. This works for now, matches
what other players such as `mpegts.js` do, and is closer to what Safari
MME (required on iPhone) needs anyway. (With MME, apparently you have to
use `video.srcObject`, or the `MediaSource`'s `sourceopen` event will
never fire.)
2024-04-16 17:08:16 -07:00
Scott Lamb a65994ba71 match VS Code extension rename 2024-04-16 16:44:36 -07:00
Scott Lamb ef98f60241 mention michioxd's #315 changes 2024-04-16 16:43:57 -07:00
Scott Lamb 7f4b04ee8a list ui selector layout fixes
* The `DisplaySelector` wasn't getting the correct flex layout.
  Before this was done by a manual style on a `Paper` element. That
  broke when adding the inner `<CardContent>` to because that's the
  container that needs the `display: "flex"`. But really, it's clearer
  to do this with `<FormGroup>` anyway, so do that.

* Switch from `<Card><CardContent>` to `<Paper sx={{ padding: ... }}>`.
  The card content has extra padding (16 px in general, 24 at the bottom
  of the last element to fit with an action). I'm not quite sure the
  best way to remove it, and the simpler `<Paper>` seems fine for this
  use anyway.
2024-04-15 21:46:22 -07:00
michioxd 9ede361b25 switch to `pnpm` 2024-04-13 21:53:59 -07:00
michioxd 8036aa40b7 prettify code 2024-04-13 21:53:59 -07:00
michioxd a787703a31 added `jsdom` to dev deps 2024-04-13 21:53:59 -07:00
michioxd 3f4cee7ead Switch to `pnpm` 2024-04-13 21:53:59 -07:00
michioxd c67a5ffba5 Added more dark background for select camera and fullscreen button. 2024-04-13 21:53:59 -07:00
michioxd 305deaa1e7 Set default theme mode to based on system color scheme 2024-04-13 21:53:59 -07:00
michioxd 29cafc2f82 Fixed code that didn't meet the eslint rule. 2024-04-13 21:53:59 -07:00
michioxd 60c6247ef9 Fixed `TypeError: window.matchMedia is not a function` during testing via vitest. 2024-04-13 21:53:59 -07:00
michioxd 317b8e9484 Resolved know problem. 2024-04-13 21:53:59 -07:00
michioxd 91e02eba7a Add copyright/license header for each components 2024-04-13 21:53:59 -07:00
michioxd 5b5822900d Update lock file for `npm` 2024-04-13 21:53:59 -07:00
michioxd b46d3acabb force color white in camera selector due to light theme 2024-04-13 21:53:59 -07:00
michioxd 6e81b27d1a Extra change for Moonfire WebUI 2024-04-13 21:53:59 -07:00
Scott Lamb dbf6c2f476 prepare v0.7.13 2024-02-12 18:15:56 -08:00
Scott Lamb eef18372cc fix inverted cond in combining 2024-02-12 18:04:47 -08:00
Scott Lamb 1f7c4c184a seamlessly merge minor VSE changes
Improves #302.
2024-02-12 17:35:27 -08:00
Scott Lamb f385215d6e clippy 2024-02-12 17:32:37 -08:00
dependabot[bot] f3da22fc5c Bump vite from 5.0.10 to 5.0.12 in /ui
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.10 to 5.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-31 22:49:25 -08:00
dependabot[bot] 65b3d54466 Bump follow-redirects from 1.15.3 to 1.15.4 in /ui
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-31 22:49:11 -08:00
dependabot[bot] 7beff8e1c9 Bump h2 from 0.3.22 to 0.3.24 in /server
---
updated-dependencies:
- dependency-name: h2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-31 22:48:14 -08:00
Scott Lamb 9592fe24e8 small tweaks to docker instructions
* strongly encourage the single-binary approach and say why.
* fix a broken link in troubleshooting guide (and regenerate toc).
* add a couple more comments to the docker compose snippet
2024-01-31 20:15:11 -08:00
Scott Lamb b47310644d update gitignore to reflect webpack->vi move 2024-01-31 20:15:04 -08:00
Scott Lamb 6f472256ab mention ui development proxy too 2024-01-31 17:12:44 -08:00
Scott Lamb d1c033b46d fix docs to mention UI bundling
see #305
2024-01-31 17:07:11 -08:00
Scott Lamb 223da03e36 v0.7.12: update Retina 2024-01-08 21:21:09 -08:00
Scott Lamb 4d4d786cde update indexmap 2024-01-06 11:54:33 -08:00
Scott Lamb 86816e862a update some Rust dependencies
I didn't go to quite the latest version of everything, in an effort to
minimize duplicates in the cargo tree.
2024-01-06 11:41:28 -08:00
Scott Lamb 2bcee02ea6 use recommended `userEvent` style
Noticed this while looking at these `act` warnings. I didn't manage to
solve those, but at least this makes the tests more consistent with
current docs.
2023-12-30 12:26:29 -08:00
Scott Lamb 77720a09e3 fix vitest busy loops 2023-12-30 11:59:24 -08:00
Scott Lamb 38eba846f8 use `unix-excl` sqlite3 vfs 2023-12-29 16:41:11 -08:00
Scott Lamb 2da459dae2 address `SQLITE_IOERR_GETTEMPPATH` from docker 2023-12-29 16:23:38 -08:00
Scott Lamb cca430b701 prep 0.7.11 2023-12-29 15:37:58 -08:00
Scott Lamb 7d12e8033d upgrade Rust deps including Retina 2023-12-29 15:30:17 -08:00
Scott Lamb e9a25322b5 upgrade msw 1->2, fix network error case
In the upgrade I managed to dust off some tests that I'd been skipping
for quite a while. It turns out one of them was pointing out a real
problem: in the network error case, we didn't display the error to the
user properly. It's really sad this reaches our code as a `TypeError`,
but it is what it is.
2023-12-18 17:08:19 -08:00
Scott Lamb 3911334fee switch to vitest 2023-12-18 17:08:09 -08:00
Scott Lamb 24880a5c2d switch from create-react-app to vite
create-react-app is apparently deprecated, so the cool kids use vite,
I guess.
2023-12-18 17:08:09 -08:00
Scott Lamb 79af39f35e add a couple missing imports
(not entirely sure why it works without them but may be complicating
an upgrade)
2023-12-17 16:21:52 -08:00
Scott Lamb 14d1879ccd fix #290 2023-12-17 15:57:52 -08:00
Scott Lamb 3de62eb70d ui test cleanups
* update `msw` 0.49 -> 1.x. (2.x is out now, but small steps.)
* wrap some `jest.{runOnlyPendingTimers,advanceTimersByTime}` calls
  in `act`.
* extend a timeout that had no slack at all
2023-12-09 14:59:40 -08:00
Scott Lamb f493ad94eb typo fix 2023-12-05 09:11:41 -08:00
Scott Lamb 672647730d typo fix 2023-12-05 09:08:30 -08:00
Scott Lamb 1013c35791 adjust table layout
The wide command reference caused the whole table around the `<details>`
to have a scroll bar, which was confusing.
2023-12-05 09:03:56 -08:00
Scott Lamb c5f5bd39ee improve docs a bit
* use a more recent screenshot for selecting tag on github UI, and
  put in a `<details>` because it's annoyingly large.
* put a border around `<details>` so it's easy to see where it
  starts/ends
* add troubleshooting info for docker setup, to help with e.g. #296.
2023-12-05 08:55:38 -08:00
Scott Lamb 882596c7f6 prep v0.7.10 2023-11-28 10:37:23 -08:00
Scott Lamb 8efea4526d build docker images again 2023-11-28 10:32:10 -08:00
Scott Lamb 5b1f7807f9 changelog updates 2023-11-28 10:29:12 -08:00
Scott Lamb 1f7806108c upgrade `@mui/x-date-pickers` to v6 beta
Fixes #256
2023-11-28 10:26:52 -08:00
Leandro Silva 5e00217784 Fix TimeoutStartSec invalid parsing in moonfire-nvr.service Systemd config
Systemd syntax expects comment in a single line, hence the parsing failure
https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html

Before;
```
[leandro@nuc ~]$ sudo systemd-analyze verify /etc/systemd/system/moonfire-nvr.service
/etc/systemd/system/moonfire-nvr.service:16: Failed to parse TimeoutStartSec= parameter, ignoring: 300 # large installations take a while to scan the sample file dirs
[leandro@nuc ~]$
```

After;
```
[leandro@nuc ~]$ sudo systemd-analyze verify /etc/systemd/system/moonfire-nvr.service
[leandro@nuc ~]$
```
2023-10-23 20:18:50 -07:00
Leandro Silva 5ea5d27908 Fix typo in v0.7.8 change log note 2023-10-21 20:13:49 -07:00
Scott Lamb 4a0cb6e62d fix incorrect comment
Fixes #288
2023-10-20 22:13:01 -07:00
Scott Lamb a2d243d3a4 support systemd socket activation 2023-10-20 21:44:34 -07:00
Scott Lamb 89ee2d0269 systemd Ready/Stopping notification 2023-10-20 11:51:55 -07:00
89 changed files with 11290 additions and 34167 deletions

View File

@ -63,18 +63,20 @@ jobs:
name: Node ${{ matrix.node }}
strategy:
matrix:
node: [ "14", "16", "18" ]
node: [ "18", "20", "21" ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.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
# Install pnpm then use pnpm instead npm
- run: npm i -g pnpm
- run: cd ui && pnpm i --frozen-lockfile
- run: cd ui && pnpm run build
- run: cd ui && pnpm run test
- run: cd ui && pnpm run lint
- run: cd ui && pnpm run check-format
license:
name: Check copyright/license headers
runs-on: ubuntu-20.04

View File

@ -4,6 +4,9 @@ defaults:
run:
shell: bash
env:
DOCKER_TAG: "ghcr.io/${{ github.repository }}:${{ github.ref_name }}"
on:
push:
tags:
@ -25,14 +28,15 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
- run: cd ui && npm ci
- run: cd ui && npm run build
- run: cd ui && npm run test
- run: npm i -g pnpm
- run: cd ui && pnpm i --frozen-lockfile
- run: cd ui && pnpm run build
- run: cd ui && pnpm run test
# Upload the UI and changelog as *job* artifacts (not *release* artifacts), used below.
- uses: actions/upload-artifact@v3
with:
name: moonfire-nvr-ui-${{ github.ref_name }}
path: ui/build
path: ui/dist
if-no-files-found: error
- uses: actions/upload-artifact@v3
with:
@ -42,12 +46,22 @@ jobs:
cross:
needs: base # for bundled ui
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
- target: aarch64-unknown-linux-musl
- target: armv7-unknown-linux-musleabihf
# Note: keep these arches in sync with `Upload Docker Manifest` list.
- arch: x86_64 # as in `uname -m` on Linux.
rust_target: x86_64-unknown-linux-musl # as in <https://doc.rust-lang.org/rustc/platform-support.html>
docker_platform: linux/amd64 # as in <https://docs.docker.com/build/building/multi-platform/>
- arch: aarch64
rust_target: aarch64-unknown-linux-musl
docker_platform: linux/arm64
- arch: armv7l
rust_target: armv7-unknown-linux-musleabihf
docker_platform: linux/arm/v7
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -56,32 +70,47 @@ jobs:
uses: actions/download-artifact@v3
with:
name: moonfire-nvr-ui-${{ github.ref_name }}
path: ui/build
path: ui/dist
# actions-rust-cross doesn't actually use cross for x86_64.
# Install the needed musl-tools in the host.
- name: Install musl-tools
run: sudo apt-get --option=APT::Acquire::Retries=3 update && sudo apt-get --option=APT::Acquire::Retries=3 install musl-tools
if: matrix.target == 'x86_64-unknown-linux-musl'
if: matrix.rust_target == 'x86_64-unknown-linux-musl'
- name: Build
uses: houseabsolute/actions-rust-cross@v0
env:
UI_BUILD_DIR: ../ui/build
UI_BUILD_DIR: ../ui/dist
# cross doesn't install `git` within its Docker container, so plumb
# the version through rather than try `git describe` from `build.rs`.
VERSION: ${{ github.ref_name }}
with:
working-directory: server
target: ${{ matrix.target }}
target: ${{ matrix.rust_target }}
command: build
args: --release --features bundled
- name: Upload Docker Artifact
run: |
tag="${DOCKER_TAG}-${{ matrix.arch }}"
mkdir output
ln ./server/target/${{ matrix.rust_target }}/release/moonfire-nvr output/
echo ${{secrets.GITHUB_TOKEN}} | docker login --username ${{github.actor}} --password-stdin ghcr.io
docker build --platform ${{ matrix.docker_platform }} --push --tag "${tag}" --file - output <<EOF
FROM scratch
LABEL org.opencontainers.image.title="Moonfire NVR"
LABEL org.opencontainers.image.description="security camera network video recorder"
LABEL org.opencontainers.image.source="https://github.com/${{ github.repository }}"
LABEL org.opencontainers.image.version="${{ github.ref_name }}"
COPY --chown=root:root --chmod=755 ./moonfire-nvr /usr/local/bin/moonfire-nvr
ENTRYPOINT ["/usr/local/bin/moonfire-nvr"]
EOF
# Upload as a *job* artifact (not *release* artifact), used below.
- name: Upload
- name: Upload Job Artifact
uses: actions/upload-artifact@v3
with:
name: moonfire-nvr-${{ github.ref_name }}-${{ matrix.target }}
path: server/target/${{ matrix.target }}/release/moonfire-nvr
name: moonfire-nvr-${{ github.ref_name }}-${{ matrix.arch }}
path: output/moonfire-nvr
if-no-files-found: error
release:
@ -89,6 +118,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/download-artifact@v3
with:
@ -101,6 +131,13 @@ jobs:
(cd artifacts; for i in moonfire-nvr-*/moonfire-nvr; do mv $i "../$(dirname $i)"; done)
- name: ls after rearranging
run: find . -ls
- name: Upload Docker Manifest (ghcr.io)
run: |
echo ${{secrets.GITHUB_TOKEN}} | docker login --username ${{github.actor}} --password-stdin ghcr.io
# Note: keep these arches in sync with `cross` job matrix.
docker manifest create "$DOCKER_TAG" "${DOCKER_TAG}"-{x86_64,aarch64,armv7l}
docker manifest push "$DOCKER_TAG"
- name: Create GitHub release
uses: softprops/action-gh-release@v1
with:

View File

@ -7,7 +7,8 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"rust-lang.rust-analyzer",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"vitest.explorer"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": [

10
.vscode/settings.json vendored
View File

@ -35,11 +35,7 @@
}
],
// 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
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"markdown.extension.list.indentationSize": "inherit",
"markdown.extension.toc.unorderedList.marker": "*",
@ -47,5 +43,7 @@
// Specify the path to the workspace version of TypeScript. Note this only
// takes effect when workspace version is selected in the UI.
// https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-the-workspace-version-of-typescript
"typescript.tsdk": "./ui/node_modules/typescript/lib"
"typescript.tsdk": "./ui/node_modules/typescript/lib",
"cmake.configureOnOpen": false,
"vitest.enable": true
}

View File

@ -1,3 +1,4 @@
Scott Lamb <slamb@slamb.org>
Dolf Starreveld <dolf@starreveld.com>
Sky1e <me@skye-c.at>
Sky1e <me@skye-c.at>
michioxd <michio.haiyaku@gmail.com>

View File

@ -8,13 +8,74 @@ upgrades, e.g. `v0.6.x` -> `v0.7.x`. The config file format and
[API](ref/api.md) currently have no stability guarantees, so they may change
even on minor releases, e.g. `v0.7.5` -> `v0.7.6`.
## unreleased
* in UI's list view, add a tooltip on the end time which shows why the
recording ended.
## v0.7.16 (2024-05-30)
* further changes to improve Reolink camera compatibility.
## v0.7.15 (2024-05-26)
* update Retina to 0.4.8, improving compatibility with some Reolink cameras.
See [retina#102](https://github.com/scottlamb/retina/issues/102).
## v0.7.14 (2024-04-16)
* Many UI improvements in [#315](https://github.com/scottlamb/moonfire-nvr/pull/315)
from [@michioxd](https://github.com/michioxd). See the PR description for
full details, including screenshots.
* dark/light modes
* redesigned login dialog
* live view: new dual camera layout, more descriptive layout names,
full screen option, re-open with last layout and camera selection
* list view: filter button becomes outlined when enabled
* Fix [#286](https://github.com/scottlamb/moonfire-nvr/issues/286):
live view now works on Firefox! Formerly, it'd fail with messages such as
`Security Error: Content at https://mydomain.com/ may not load data from blob:https://mydomain.com/44abc5dc-750d-48d1-817d-2e6a52445592`.
## v0.7.13 (2024-02-12)
* seamlessly merge together recordings which have imperceptible changes in
their `VideoSampleEntry`. Improves
[#302](https://github.com/scottlamb/moonfire-nvr/issues/302).
## v0.7.12 (2024-01-08)
* update to Retina 0.4.7, supporting RTSP servers that do not set
a `Content-Type` in their `DESCRIBE` responses
## v0.7.11 (2023-12-29)
* upgrade some Rust dependencies. Most notably, Retina 0.4.6 improves camera
compatibility.
* update frontend build and tests from no-longer-maintained create-react-app
to vite and vitest.
## v0.7.10 (2023-11-28)
* build docker images again
* upgrade date/time pickers to `@mui/x-date-pickers` v6 beta, improving
time entry. Fixes
[#256](https://github.com/scottlamb/moonfire-nvr/issues/256).
## v0.7.9 (2023-10-21)
* `systemd` integration on Linux
* notify `systemd` on starting/stopping. To take advantage of this, you'll
need to modify your `/etc/systemd/moonfire-nvr.service`. See
[`guide/install.md`](guide/install.md).
* socket activation. See [`ref/config.md`](ref/config.md).
## v0.7.8 (2023-10-18)
* release as self-contained Linux binaries (for `x86_64`, `aarch64`, and
`armv8` architectures) rather than Docker images. This minimizes hassle and
total download size. Along the way, we switched libc to from `glibc` to
`musl` in the process. Please report any problems with the build or
instructions!
total download size. Along the way, we switched libc from `glibc` to `musl`.
Please report any problems with the build or instructions!
## v0.7.7 (2023-08-03)

View File

@ -38,7 +38,9 @@ can skip this if compiling with `--features=rusqlite/bundled` and don't
mind the `moonfire-nvr sql` command not working.
To build the UI, you'll need a [nodejs](https://nodejs.org/en/download/) release
in "Maintenance" or "LTS" status: currently v14, v16, or v18.
in "Maintenance", "LTS", or "Current" status on the
[Release Schedule](https://github.com/nodejs/release#release-schedule):
currently v18, v20, or v21.
On recent Ubuntu or Raspbian Linux, the following command will install
most non-Rust dependencies:
@ -82,17 +84,24 @@ $ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin
$ cd ..
```
You can build the UI via `npm` and find it in the `ui/build` directory:
You can build the UI via `pnpm` and find it in the `ui/build` directory:
```console
$ cd ui
$ npm install
$ npm run build
$ pnpm install
$ pnpm run build
$ sudo mkdir /usr/local/lib/moonfire-nvr
$ cd ..
$ sudo rsync --recursive --delete --chmod=D755,F644 ui/build/ /usr/local/lib/moonfire-nvr/ui
$ sudo rsync --recursive --delete --chmod=D755,F644 ui/dist/ /usr/local/lib/moonfire-nvr/ui
```
For more information about using `pnpm`, check out the [Developing UI Guide](./developing-ui.md#requirements).
If you wish to bundle the UI into the binary, you can build the UI first and then pass
`--features=bundled-ui` when building the server. See also the
[release workflow](../.github/workflows/release.yml) which statically links SQLite and
(musl-based) libc for a zero-dependencies binary.
### Running interactively straight from the working copy
The author finds it convenient for local development to set up symlinks so that
@ -100,7 +109,7 @@ the binaries in the working copy will run via just `nvr`:
```console
$ sudo mkdir /usr/local/lib/moonfire-nvr
$ sudo ln -s `pwd`/ui/build /usr/local/lib/moonfire-nvr/ui
$ sudo ln -s `pwd`/ui/dist /usr/local/lib/moonfire-nvr/ui
$ sudo mkdir /var/lib/moonfire-nvr
$ sudo chown $USER: /var/lib/moonfire-nvr
$ ln -s `pwd`/server/target/release/moonfire-nvr $HOME/bin/moonfire-nvr

View File

@ -1,4 +1,4 @@
# Working on UI development <!-- omit in toc -->
# Developing the UI <!-- omit in toc -->
* [Getting started](#getting-started)
* [Overriding defaults](#overriding-defaults)
@ -6,7 +6,7 @@
The UI is presented from a single HTML page (index.html) and any number
of Javascript files, css files, images, etc. These are "packed" together
using [webpack](https://webpack.js.org).
using [vite](https://vitejs.dev/).
For ongoing development it is possible to have the UI running in a web
browser using "hot loading". This means that as you make changes to source
@ -20,18 +20,26 @@ and more effort is expended on packing and minimizing the components of
the application as represented in the various "bundles". Read more about
this in the webpack documentation.
## Requirements
* Node.js v18+
* `pnpm` installed
This guide below will use [`pnpm`](https://pnpm.io/) as package manager instead
`npm`. So we highly recommended you to use `pnpm` in this project.
## Getting started
Checkout the branch you want to work on and type
```
$ cd ui
$ npm run start
```bash
cd ui
pnpm run dev
```
This will pack and prepare a development setup. By default the development
server that serves up the web page(s) will listen on
[http://localhost:3000/](http://localhost:3000/) so you can direct your browser
[http://localhost:5173/](http://localhost:5173/) so you can direct your browser
there. It assumes the Moonfire NVR server is running at
[http://localhost:8080/](http://localhost:8080/) and will proxy API requests
there.
@ -45,32 +53,25 @@ process, but some will show up in the browser console, or both.
## Overriding defaults
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:
Currently there's only one supported environment variable override defined in
`ui/vite.config.ts`:
| 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` |
| variable | description | default |
| :------------- | :------------------------------------------ | :----------------------- |
| `PROXY_TARGET` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
Thus one could connect to a remote Moonfire NVR by specifying its URL as
follows:
```
$ PROXY_TARGET=https://nvr.example.com/ npm run start
```bash
PROXY_TARGET=https://nvr.example.com/ npm run dev
```
This allows you to test a new UI against your stable, production Moonfire NVR
installation with real data.
**Note:** the live stream currently does not work in combination with
`PROXY_TARGET` due to [#290](https://github.com/scottlamb/moonfire-nvr/issues/290).
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/).
[vitejs.dev: Env Variables and Modes](https://vitejs.dev/guide/env-and-mode).
## A note on `https`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -12,23 +12,138 @@ via the prebuilt Linux binaries available for x86-64, arm64, and arm. If you
instead want to build Moonfire NVR yourself, see the [Build
instructions](build.md).
First, make sure you are viewing instructions that match the release you intend
<table><tr><td><details>
<summary>Go to the instructions for your exact Moonfire version</summary>
Make sure you are viewing instructions that match the release you intend
to install. When viewing this page on Github, look for a pull-down in the upper
left, and pick the [latest tagged version](https://github.com/scottlamb/moonfire-nvr/releases/latest):
![Selecting a version of install instructions](install-version.png)
<img src="install-version.png" height=367 alt="Selecting a version of install instructions">
</details></td></tr></table>
Download the binary for your platform from the matching GitHub release.
Install it as `/usr/local/bin/moonfire-nvr` and ensure it is executable, e.g.
for version `v0.7.8` on Intel machines:
for version `v0.7.14`:
```console
$ VERSION=v0.7.8
$ ARCH=x86_64-unknown-linux-musl
$ VERSION=v0.7.14
$ ARCH=$(uname -m)
$ curl -OL "https://github.com/scottlamb/moonfire-nvr/releases/download/$VERSION/moonfire-nvr-$VERSION-$ARCH"
$ sudo install -m 755 "moonfire-nvr-$VERSION-$ARCH" /usr/local/bin/moonfire-nvr
```
<table><tr><td><details>
<summary>Docker</summary>
The procedure above, in which Moonfire runs directly on the host, is strongly
recommended.
* The single binary installed in `/usr/local/bin` has zero dependencies.
It is statically linked and bundles the UI. It just works. There's no
complex distribution-specific install procedures or danger of conflicting
version requirements between Moonfire and other software. These are the same
problems most people use Docker to solve.
* Moonfire's recommended install method used to involve Docker. In our
experience, complexity around Docker commands, filesystem/process namespace
mappings, broken seccomp defaults that do not allow standard system calls
like `clock_gettime`, etc. has been a major frustration for folks installing
Moonfire. Now that we have the zero-dependencies binary, we recommend
sidestepping all of this and have rewritten the docs accordingly.
…but, you may still prefer Docker for familiarity or other reasons. If so, you
can install the [`ghcr.io/scottlamb/moonfire-nvr`](https://github.com/scottlamb/moonfire-nvr/pkgs/container/moonfire-nvr) Docker images instead. We'll
assume you know your way around your preferred tools and can adapt the
instructions to the workflow you use with Docker. You may find the following
Docker compose snippet useful:
```yaml
version: 3
services:
moonfire-nvr:
# The `vX.Y.Z` images will work on any architecture (x86-64, arm, or
# aarch64); just pick the correct version.
image: ghcr.io/scottlamb/moonfire-nvr:v0.7.11
command: run
volumes:
# Pass through `/var/lib/moonfire-nvr` from the host.
- "/var/lib/moonfire-nvr:/var/lib/moonfire-nvr"
# Pass through `/etc/moonfire-nvr.toml` from the host.
# Be sure to create `/etc/moonfire-nvr.toml` first (see below).
# Docker will "helpfully" create a directory by this name otherwise.
- "/etc/moonfire-nvr.toml:/etc/moonfire-nvr.toml:ro"
# Pass through `/var/tmp` from the host.
# SQLite expects to be able to create temporary files in this dir, which
# is not created in Moonfire's minimal Docker image.
# See: <https://www.sqlite.org/tempfiles.html>
- "/var/tmp:/var/tmp"
# Add additional mount lines here for each sample file directory
# outside of /var/lib/moonfire-nvr, e.g.:
# - "/media/nvr:/media/nvr"
# The Docker image doesn't include the time zone database; you must mount
# it from the host for Moonfire to support local time.
- "/usr/share/zoneinfo:/usr/share/zoneinfo:ro"
# Edit this to match your `moonfire-nvr` user.
# Note that Docker will not honor names from the host here, even if
# `/etc/passwd` is passed through.
# - Be sure to run the `useradd` command below first.
# - Then run `echo $(id -u moonfire-nvr):$(id -g moonfire-nvr)` to see
# what should be filled in here.
user: UID:GID
# Uncomment this if Moonfire fails with `clock_gettime failed` (likely on
# older 32-bit hosts). <https://github.com/moby/moby/issues/40734>
# security_opt:
# - seccomp:unconfined
environment:
# Edit zone below to taste. The `:` is functional.
TZ: ":America/Los_Angeles"
RUST_BACKTRACE: 1
# docker's default log driver won't rotate logs properly, and will throw
# away logs when you destroy and recreate the container. Using journald
# solves these problems.
# <https://docs.docker.com/config/containers/logging/configure/>
logging:
driver: journald
options:
tag: moonfire-nvr
restart: unless-stopped
ports:
- "8080:8080/tcp"
```
Command reference:
<table>
<tr><th colspan="2">Initialize the database</th></tr>
<tr><th>Non-Docker</th><td><code>sudo -u moonfire-nvr moonfire-nvr init</code></td></tr>
<tr><th>Docker</th><td><code>sudo docker compose run --rm moonfire-nvr init</code></td></tr>
<tr><th colspan="2">Run interactive configuration</th></tr>
<tr><th>Non-Docker</th><td><code>sudo -u moonfire-nvr moonfire-nvr config 2>debug-log</code></td></tr>
<tr><th>Docker</th><td><code>sudo docker compose run --rm moonfire-nvr config 2>debug-log</code></td></tr>
<tr><th colspan="2">Enable and start the server</th></tr>
<tr><th>Non-Docker<td><code>sudo systemctl enable --now moonfire-nvr</code></td></tr>
<tr><th>Docker</th><td><code>sudo docker compose up --detach moonfire-nvr</code></td></tr>
</table>
</details></td></tr></table>
Next, you'll need to set up your filesystem and the Moonfire NVR user.
Moonfire NVR keeps two kinds of state:
@ -119,15 +234,15 @@ by using the `moonfire-nvr` binary's text-based configuration tool.
$ sudo -u moonfire-nvr moonfire-nvr config 2>debug-log
```
<details>
<summary>Did it return without doing anything?</summary>
<table><tr><td><details>
<summary>Did it return without doing anything?</summary>
If `moonfire-nvr config` returns you to the console prompt right away, look in
the `debug-log` file for why. One common reason is that you have Moonfire NVR
running; you'll need to shut it down first. If you are running a systemd
service as described below, try `sudo systemctl stop moonfire-nvr` before
editing the config and `sudo systemctl start moonfire-nvr` after.
</details>
</details></td></tr></table>
In the user interface,
@ -212,7 +327,9 @@ Environment=TZ=:/etc/localtime
Environment=MOONFIRE_FORMAT=systemd
Environment=MOONFIRE_LOG=info
Environment=RUST_BACKTRACE=1
Type=simple
Type=notify
# large installations take a while to scan the sample file dirs
TimeoutStartSec=300
User=moonfire-nvr
Restart=on-failure
CPUAccounting=true

View File

@ -10,6 +10,11 @@ need more help.
* [Slow operations](#slow-operations)
* [Camera stream errors](#camera-stream-errors)
* [Problems](#problems)
* [Docker setup](#docker-setup)
* [`"/etc/moonfire-nvr.toml" is a directory`](#etcmoonfire-nvrtoml-is-a-directory)
* [`Error response from daemon: unable to find user UID: no matching entries in passwd file`](#error-response-from-daemon-unable-to-find-user-uid-no-matching-entries-in-passwd-file)
* [`clock_gettime failed: EPERM: Operation not permitted`](#clock_gettime-failed-eperm-operation-not-permitted)
* [`VFS is unable to determine a suitable directory for temporary files`](#vfs-is-unable-to-determine-a-suitable-directory-for-temporary-files)
* [Server errors](#server-errors)
* [`Error: pts not monotonically increasing; got 26615520 then 26539470`](#error-pts-not-monotonically-increasing-got-26615520-then-26539470)
* [Out of disk space](#out-of-disk-space)
@ -185,6 +190,64 @@ quickly enough. In the latter case, you'll likely see a
## Problems
### Docker setup
If you are using the Docker compose snippet mentioned in the
[install instructions](install.md), you might run into a few unique problems.
#### `"/etc/moonfire-nvr.toml" is a directory`
If you try running the Docker container with its
`/etc/moonfire-nvr.toml:/etc/moonfire-nvr.toml:ro` mount before creating the
config file, Docker will "helpfully" create it as a directory. Shut down
the Docker container, remove the directory, create the config file,
and try again.
#### `Error response from daemon: unable to find user UID: no matching entries in passwd file`
If Docker produces this error, look at this section of the docker compose setup:
```yaml
# Edit this to match your `moonfire-nvr` user.
# Note that Docker will not honor names from the host here, even if
# `/etc/passwd` is passed through.
# - Be sure to run the `useradd` command below first.
# - Then run `echo $(id -u moonfire-nvr):$(id -g moonfire-nvr)` to see
# what should be filled in here.
user: UID:GID
```
#### `clock_gettime failed: EPERM: Operation not permitted`
If commands fail with an error like the following, you're likely running
Docker with an overly restrictive `seccomp` setup. [This stackoverflow
answer](https://askubuntu.com/questions/1263284/apt-update-throws-signature-error-in-ubuntu-20-04-container-on-arm/1264921#1264921) describes the
problem in more detail. The simplest solution is to uncomment
the `- seccomp: unconfined` line in your Docker compose file.
```console
$ sudo docker compose run --rm moonfire-nvr --version
clock_gettime failed: EPERM: Operation not permitted
This indicates a broken environment. See the troubleshooting guide.
```
#### `VFS is unable to determine a suitable directory for temporary files`
Moonfire NVR's database internally uses SQLite, which creates
[various temporary files](https://www.sqlite.org/tempfiles.html). If it can't
find a path that exists and is writable by the current user, it will produce
errors such as the following:
```
2023-12-29T16:16:47.795330 WARN sync-1 syncer{path=/media/nvr/sample}: moonfire_db::writer: flush failure on save for reason 120 sec after start of 59 seconds driveway-sub recording 10/1222348; will retry after PT60S: UNAVAILABLE
caused by: disk I/O error
caused by: Error code 6410: VFS is unable to determine a suitable directory for temporary files
```
The simplest solution is to pass `/var/tmp` through from the host to the Docker
container in your Docker compose file.
### Server errors
#### `Error: pts not monotonically increasing; got 26615520 then 26539470`

View File

@ -342,6 +342,7 @@ arbitrary order. Each recording object has the following properties:
together are as described. Adjacent recordings from the same RTSP session
may be coalesced in this fashion to reduce the amount of redundant data
transferred.
* `runStartId`. The id of the first recording in this run.
* `firstUncommitted` (optional). If this range is not fully committed to the
database, the first id that is uncommitted. This is significant because
it's possible that after a crash and restart, this id will refer to a
@ -374,6 +375,9 @@ arbitrary order. Each recording object has the following properties:
and Moonfire NVR fills in a duration of 0. When using `/view.mp4`, it's
not possible to append additional segments after such frames, as noted
below.
* `endReason`: the reason the recording ended. Absent if the recording did
not end (`growing` is true or this was split via `split90k`) or if the
reason was unknown (recording predates schema version 7).
Under the property `videoSampleEntries`, an object mapping ids to objects with
the following properties:

View File

@ -14,6 +14,8 @@ lines, meant to be more easily edited by humans.
## Examples
### Starter config
The following is a starter config which allows connecting and viewing video with no authentication:
```toml
@ -26,6 +28,8 @@ unix = "/var/lib/moonfire-nvr/sock"
ownUidIsPrivileged = true
```
### Authenticated config
The following is for a more secure setup with authentication and a TLS proxy
server in front, as in [guide/secure.md](../guide/secure.md).
@ -39,13 +43,71 @@ unix = "/var/lib/moonfire-nvr/sock"
ownUidIsPrivileged = true
```
### `systemd` socket activation
`systemd` socket activation (Linux-only) expects `systemd` to create the sockets
on behalf of Moonfire NVR. This can speed startup of services that depend on them and allow
Moonfire to bind to privileged ports (80 or 443) without root privileges. The latter is
expected to be more useful once
[moonfire-nvr#27](https://github.com/scottlamb/moonfire-nvr/issues/27) is
complete and Moonfire is suitable for direct use as an Internet-facing webserver.
To set this up, you'll need an additional systemd unit file for each socket and
to reference them from `/etc/moonfire-nvr.toml`. Be sure to run `sudo systemctl
daemon-reload` to tell `systemd` to read in the new unit files. Your
`moonfire-nvr.service` file should also `Requires=` each socket file.
#### `/etc/moonfire-nvr.toml`
```toml
[[binds]]
systemd = "moonfire-nvr-tcp.socket"
allowUnauthenticatedPermissions = { viewVideo = true }
[[binds]]
systemd = "moonfire-nvr-unix.socket"
ownUidIsPrivileged = true
```
### `/etc/systemd/system/moonfire-nvr.service`
```ini
[Unit]
Requires=moonfire-nvr-tcp.socket
Requires=moonfire-nvr-unix.socket
# ...rest as before...
```
### `/etc/systemd/system/moonfire-nvr-tcp.socket`
```ini
[Socket]
ListenStream=80
Service=moonfire-nvr.service
```
### `/etc/systemd/system/moonfire-nvr-unix.socket`
```ini
[Socket]
ListenStream=/var/lib/moonfire-nvr/sock
Service=moonfire-nvr.service
```
## Reference
At the top level, before any `[[bind]]` lines, the following
keys are understood:
* `dbDir`: path to the SQLite database directory. Defaults to `/var/lib/moonfire-nvr/db`.
* `uiDir`: path to the UI to serve. Defaults to `/usr/local/lib/moonfire-nvr/ui`.
* `uiDir`: UI to serve; can be a path. Defaults to the special value
`uiDir = { bundled = true }` if a UI was built into the binary, or
`/usr/local/lib/moonfire-nvr/ui` otherwise. Release builds have UIs
built in; you can replicate this yourself via `--features=bundled` or `--features=bundled-ui`
when [building the server](../guide/build.md). **Note:** it's unusual
to override this value. For UI development, a much more pleasant
workflow is to use a hot-reloading proxy server as described in
[this guide](../guide/developing-ui.md).
* `workerThreads`: number of [tokio](https://tokio.rs/) worker threads to
use. Defaults to the number of CPUs on the system. This normally does not
need to be changed, but reducing it may slightly lower idle CPU usage.
@ -55,7 +117,7 @@ should start with a `[[binds]]` line and specify one of the following:
* `ipv4`: an IPv4 socket address. `0.0.0.0:8080` would allow connections from outside the machine;
`127.0.0.1:8080` would allow connections only from the local host.
* `ipv6`: an IPv6 socket address. [::0]:8080` would allow connections from outside the machine;
* `ipv6`: an IPv6 socket address. `[::0]:8080` would allow connections from outside the machine;
`[[::1]:8080` would allow connections from only the local host.
* `unix`: a path in the local filesystem where a UNIX-domain socket can be created. Permissions on the
enclosing directories control which users are allowed to connect to it. Web browsers typically don't
@ -68,6 +130,9 @@ should start with a `[[binds]]` line and specify one of the following:
Moonfire NVR instance on `nvr-host` via https://localhost:8080/. If
`ownUidIsPrivileged` is specified (see below), it will additionally
have all permissions.
* `systemd` (Linux-only): a name of a socket passed from `systemd`. See
[`systemd.socket(5)`](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html)
for more information, or the example above.
Additional options within `[[binds]]`:

1251
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,13 +23,19 @@ bundled-ui = []
members = ["base", "db"]
[workspace.dependencies]
nix = "0.26.1"
base64 = "0.21.0"
h264-reader = "0.7.0"
itertools = "0.12.0"
nix = "0.27.0"
pretty-hex = "0.4.0"
ring = "0.17.0"
rusqlite = "0.30.0"
tracing = { version = "0.1", features = ["log"] }
rusqlite = "0.28.0"
tracing-log = "0.2"
[dependencies]
base = { package = "moonfire-base", path = "base" }
base64 = "0.13.0"
base64 = { workspace = true }
blake3 = "1.0.0"
bpaf = { version = "0.9.1", features = ["autocomplete", "bright-color", "derive"]}
bytes = "1"
@ -38,22 +44,22 @@ chrono = "0.4.23"
cursive = { version = "0.20.0", default-features = false, features = ["termion-backend"] }
db = { package = "moonfire-db", path = "db" }
futures = "0.3"
fnv = "1.0"
h264-reader = "0.6.0"
h264-reader = { workspace = true }
http = "0.2.3"
http-serve = { version = "0.3.1", features = ["dir"] }
hyper = { version = "0.14.2", features = ["http1", "server", "stream", "tcp"] }
itertools = "0.10.0"
itertools = { workspace = true }
libc = "0.2"
log = { version = "0.4" }
memchr = "2.0.2"
nix = { workspace = true}
nix = { workspace = true, features = ["time", "user"] }
nom = "7.0.0"
password-hash = "0.4.2"
password-hash = "0.5.0"
pretty-hex = { workspace = true }
protobuf = "3.0"
reffers = "0.7.0"
retina = "0.4.0"
ring = "0.16.2"
ring = { workspace = true }
rusqlite = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@ -62,22 +68,25 @@ sync_wrapper = "0.1.0"
time = "0.1"
tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = "0.1.5"
tokio-tungstenite = "0.18.0"
toml = "0.5"
tokio-tungstenite = "0.20.0"
toml = "0.8"
tracing = { workspace = true }
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
tracing-core = "0.1.30"
tracing-futures = { version = "0.2.5", features = ["futures-03", "std-future"] }
tracing-log = "0.1.3"
tracing-log = { workspace = true }
ulid = "1.0.0"
url = "2.1.1"
uuid = { version = "1.1.2", features = ["serde", "std", "v4"] }
flate2 = "1.0.26"
git-version = "0.3.5"
[target.'cfg(target_os = "linux")'.dependencies]
libsystemd = "0.7.0"
[build-dependencies]
ahash = "0.8"
blake3 = "1.0.0"
fnv = "1.0"
walkdir = "2.3.3"
[dev-dependencies]
@ -104,4 +113,9 @@ lto = true
debug = 1
[patch.crates-io]
hashlink = { git = "https://github.com/scottlamb/hashlink", rev = "26715ca0efe3f1773a0a22bbde8e36cafcaaed52" }
# update to indexmap 2
protobuf-codegen = { git = "https://github.com/scottlamb/rust-protobuf.git", rev = "a61e09785c957eb9a183d129b426710146bfde38" }
protobuf-parse = { git = "https://github.com/scottlamb/rust-protobuf.git", rev = "a61e09785c957eb9a183d129b426710146bfde38" }
# This version uses fallible-iterator v0.3 (same one rusqlite 0.30 uses) and hasn't been released yet.
sdp-types = { git = "https://github.com/sdroege/sdp-types", rev = "e8d0a2c4b8b1fc1ddf1c60a01dc717a2f4e2d514" }

View File

@ -14,6 +14,7 @@ nightly = []
path = "lib.rs"
[dependencies]
ahash = "0.8"
chrono = "0.4.23"
coded = { git = "https://github.com/scottlamb/coded", rev = "2c97994974a73243d5dd12134831814f42cdb0e8"}
futures = "0.3"
@ -27,5 +28,5 @@ slab = "0.4"
time = "0.1"
tracing = { workspace = true }
tracing-core = "0.1.30"
tracing-log = "0.1.3"
tracing-log = { workspace = true }
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }

View File

@ -10,3 +10,7 @@ pub mod time;
pub mod tracing_setup;
pub use crate::error::{Error, ErrorBuilder, ErrorKind, ResultExt};
pub use ahash::RandomState;
pub type FastHashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
pub type FastHashSet<K> = std::collections::HashSet<K, ahash::RandomState>;

View File

@ -133,7 +133,7 @@ fn poll_impl(inner: &Inner, waker_i: &mut usize, cx: &mut Context<'_>) -> Poll<(
} else {
let existing_waker = &mut wakers[*waker_i];
if !new_waker.will_wake(existing_waker) {
*existing_waker = new_waker.clone();
existing_waker.clone_from(new_waker);
}
}
Poll::Pending

View File

@ -8,8 +8,8 @@ use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
const UI_BUILD_DIR_ENV_VAR: &str = "UI_BUILD_DIR";
const DEFAULT_UI_BUILD_DIR: &str = "../ui/build";
const UI_DIST_DIR_ENV_VAR: &str = "UI_DIST_DIR";
const DEFAULT_UI_DIST_DIR: &str = "../ui/dist";
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
@ -53,21 +53,21 @@ impl FileEncoding {
/// Map of "bare path" to the best representation.
///
/// A "bare path" has no prefix for the root and no suffix for encoding, e.g.
/// `favicons/blah.ico` rather than `../../ui/build/favicons/blah.ico.gz`.
/// `favicons/blah.ico` rather than `../../ui/dist/favicons/blah.ico.gz`.
///
/// The best representation is gzipped if available, uncompressed otherwise.
type FileMap = fnv::FnvHashMap<String, File>;
type FileMap = std::collections::HashMap<String, File, ahash::RandomState>;
fn stringify_files(files: &FileMap) -> Result<String, std::fmt::Error> {
let mut buf = String::new();
write!(buf, "const FILES: [BuildFile; {}] = [\n", files.len())?;
writeln!(buf, "const FILES: [BuildFile; {}] = [", files.len())?;
for (bare_path, file) in files {
let include_path = &file.include_path;
let etag = file.etag.to_hex();
let encoding = file.encoding.to_str();
write!(buf, " BuildFile {{ bare_path: {bare_path:?}, data: include_bytes!({include_path:?}), etag: {etag:?}, encoding: {encoding} }},\n")?;
writeln!(buf, " BuildFile {{ bare_path: {bare_path:?}, data: include_bytes!({include_path:?}), etag: {etag:?}, encoding: {encoding} }},")?;
}
write!(buf, "];\n")?;
writeln!(buf, "];")?;
Ok(buf)
}
@ -78,10 +78,10 @@ fn handle_bundled_ui() -> Result<(), BoxError> {
}
let ui_dir =
std::env::var(UI_BUILD_DIR_ENV_VAR).unwrap_or_else(|_| DEFAULT_UI_BUILD_DIR.to_owned());
std::env::var(UI_DIST_DIR_ENV_VAR).unwrap_or_else(|_| DEFAULT_UI_DIST_DIR.to_owned());
// If the feature is on, also re-run if the actual UI files change.
println!("cargo:rerun-if-env-changed={UI_BUILD_DIR_ENV_VAR}");
println!("cargo:rerun-if-env-changed={UI_DIST_DIR_ENV_VAR}");
println!("cargo:rerun-if-changed={ui_dir}");
let out_dir: PathBuf = std::env::var_os("OUT_DIR")
@ -113,7 +113,7 @@ fn handle_bundled_ui() -> Result<(), BoxError> {
None => {
bare_path = path;
encoding = FileEncoding::Uncompressed;
if files.get(bare_path).is_some() {
if files.contains_key(bare_path) {
continue; // don't replace with suboptimal encoding.
}
}
@ -152,6 +152,34 @@ fn handle_bundled_ui() -> Result<(), BoxError> {
Ok(())
}
/// Returns one-line `stdout` from a `git` command; `args` are simply space-separated (no escapes).
fn git_oneline_output(args: &str) -> Result<String, BoxError> {
static HELP_TEXT: &str =
"If you are building from a release archive or without the `git` CLI available, \n\
try again with the `VERSION` environment variable set";
// `output()` returns `Err` e.g. if `git` was not found.
let mut output = Command::new("git")
.args(args.split(' '))
.output()
.map_err(|e| format!("`git {args}` failed: {e}\n\n{HELP_TEXT}"))?;
// `status` is non-success if `git` launched and then failed.
if !output.status.success() {
let status = output.status;
let stderr = output.stderr.escape_ascii();
return Err(format!("`git {args}` failed with {status}: {stderr}\n\n{HELP_TEXT}").into());
}
if output.stdout.pop() != Some(b'\n') {
return Err(format!("`git {args}` stdout should end with newline").into());
}
if output.stdout.contains(&b'\n') {
return Err(format!("`git {args}` stdout should be single line").into());
}
Ok(String::from_utf8(output.stdout)
.map_err(|_| format!("`git {args}` stdout should be valid UTF-8"))?)
}
fn handle_version() -> Result<(), BoxError> {
println!("cargo:rerun-if-env-changed=VERSION");
if std::env::var("VERSION").is_ok() {
@ -164,25 +192,12 @@ fn handle_version() -> Result<(), BoxError> {
// Avoid reruns when the output doesn't meaningfully change. I don't think this is quite right:
// it won't recognize toggling between `-dirty` and not. But it'll do.
let dir = Command::new("git")
.arg("rev-parse")
.arg("--git-dir")
.output()?
.stdout;
let dir = String::from_utf8(dir).unwrap();
let dir = dir.strip_suffix('\n').unwrap();
let dir = git_oneline_output("rev-parse --git-dir")?;
println!("cargo:rerun-if-changed={dir}/logs/HEAD");
println!("cargo:rerun-if-changed={dir}/index");
// Plumb the version through.
let version = Command::new("git")
.arg("describe")
.arg("--always")
.arg("--dirty")
.output()?
.stdout;
let version = String::from_utf8(version).unwrap();
let version = version.strip_suffix('\n').unwrap();
let version = git_oneline_output("describe --always --dirty")?;
println!("cargo:rustc-env=VERSION={version}");
Ok(())

View File

@ -16,28 +16,26 @@ path = "lib.rs"
[dependencies]
base = { package = "moonfire-base", path = "../base" }
base64 = "0.13.0"
base64 = { workspace = true }
blake3 = "1.0.0"
byteorder = "1.0"
cstr = "0.2.5"
diff = "0.1.12"
fnv = "1.0"
futures = "0.3"
h264-reader = "0.6.0"
h264-reader = { workspace = true }
hashlink = "0.8.1"
itertools = "0.10.0"
itertools = { workspace = true }
libc = "0.2"
nix = "0.26.1"
nix = { workspace = true, features = ["dir", "feature", "fs", "mman"] }
num-rational = { version = "0.4.0", default-features = false, features = ["std"] }
odds = { version = "0.4.0", features = ["std-vec"] }
pretty-hex = "0.3.0"
pretty-hex = { workspace = true }
protobuf = "3.0"
ring = "0.16.2"
rusqlite = "0.28.0"
scrypt = "0.10.0"
ring = { workspace = true }
rusqlite = { workspace = true }
scrypt = "0.11.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
#similar = "2.1.0"
smallvec = "1.0"
tempfile = "3.2.0"
time = "0.1"

View File

@ -6,8 +6,9 @@
use crate::json::UserConfig;
use crate::schema::Permissions;
use base::FastHashMap;
use base::{bail, err, strutil, Error, ErrorKind, ResultExt as _};
use fnv::FnvHashMap;
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use protobuf::Message;
use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{named_params, params, Connection, Transaction};
@ -41,7 +42,8 @@ fn params() -> &'static Params {
/// For testing only: use fast but insecure hashes.
/// Call via `testutil::init()`.
pub(crate) fn set_test_config() {
let test_params = scrypt::Params::new(8, 8, 1).expect("test params should be valid");
let test_params = scrypt::Params::new(8, 8, 1, scrypt::Params::RECOMMENDED_LEN)
.expect("test params should be valid");
if let Err(existing_params) = PARAMS.set(Params {
actual: test_params,
is_test: true,
@ -282,7 +284,8 @@ pub struct RawSessionId([u8; 48]);
impl RawSessionId {
pub fn decode_base64(input: &[u8]) -> Result<Self, Error> {
let mut s = RawSessionId([0u8; 48]);
let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut s.0[..])
let l = STANDARD_NO_PAD
.decode_slice(input, &mut s.0[..])
.map_err(|e| err!(InvalidArgument, msg("bad session id"), source(e)))?;
if l != 48 {
bail!(InvalidArgument, msg("session id must be 48 bytes"));
@ -326,12 +329,15 @@ pub struct SessionHash(pub [u8; 24]);
impl SessionHash {
pub fn encode_base64(&self, output: &mut [u8; 32]) {
::base64::encode_config_slice(self.0, ::base64::STANDARD_NO_PAD, output);
STANDARD_NO_PAD
.encode_slice(self.0, output)
.expect("base64 encode should succeed");
}
pub fn decode_base64(input: &[u8]) -> Result<Self, Error> {
let mut h = SessionHash([0u8; 24]);
let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut h.0[..])
let l = STANDARD_NO_PAD
.decode_slice(input, &mut h.0[..])
.map_err(|e| err!(InvalidArgument, msg("invalid session hash"), source(e)))?;
if l != 24 {
bail!(InvalidArgument, msg("session hash must be 24 bytes"));
@ -381,7 +387,7 @@ pub(crate) struct State {
/// TODO: Add eviction of clean sessions. Keep a linked hash set of clean session hashes and
/// evict the oldest when its size exceeds a threshold. Or just evict everything on every flush
/// (and accept more frequent database accesses).
sessions: FnvHashMap<SessionHash, Session>,
sessions: FastHashMap<SessionHash, Session>,
rand: SystemRandom,
}
@ -391,7 +397,7 @@ impl State {
let mut state = State {
users_by_id: BTreeMap::new(),
users_by_name: BTreeMap::new(),
sessions: FnvHashMap::default(),
sessions: FastHashMap::default(),
rand: ring::rand::SystemRandom::new(),
};
let mut stmt = conn.prepare(
@ -652,7 +658,7 @@ impl State {
domain: Option<Vec<u8>>,
creation_password_id: Option<i32>,
flags: i32,
sessions: &'s mut FnvHashMap<SessionHash, Session>,
sessions: &'s mut FastHashMap<SessionHash, Session>,
permissions: Permissions,
) -> Result<(RawSessionId, &'s Session), base::Error> {
let mut session_id = RawSessionId([0u8; 48]);

View File

@ -12,7 +12,7 @@ use crate::raw;
use crate::recording;
use crate::schema;
use base::{err, Error};
use fnv::{FnvHashMap, FnvHashSet};
use base::{FastHashMap, FastHashSet};
use nix::fcntl::AtFlags;
use rusqlite::params;
use std::os::unix::io::AsRawFd;
@ -27,8 +27,8 @@ pub struct Options {
#[derive(Default)]
pub struct Context {
rows_to_delete: FnvHashSet<CompositeId>,
files_to_trash: FnvHashSet<(i32, CompositeId)>, // (dir_id, composite_id)
rows_to_delete: FastHashSet<CompositeId>,
files_to_trash: FastHashSet<(i32, CompositeId)>, // (dir_id, composite_id)
}
pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error> {
@ -79,7 +79,7 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
let (db_uuid, _config) = raw::read_meta(conn)?;
// Scan directories.
let mut dirs_by_id: FnvHashMap<i32, Dir> = FnvHashMap::default();
let mut dirs_by_id: FastHashMap<i32, Dir> = FastHashMap::default();
{
let mut dir_stmt = conn.prepare(
r#"
@ -229,11 +229,11 @@ struct Recording {
#[derive(Default)]
struct Stream {
recordings: FnvHashMap<i32, Recording>,
recordings: FastHashMap<i32, Recording>,
cum_recordings: Option<i32>,
}
type Dir = FnvHashMap<i32, Stream>;
type Dir = FastHashMap<i32, Stream>;
fn summarize_index(video_index: &[u8]) -> Result<RecordingSummary, Error> {
let mut it = recording::SampleIndexIterator::default();
@ -345,7 +345,7 @@ fn compare_stream(
stream
.recordings
.entry(id.recording())
.or_insert_with(Recording::default)
.or_default()
.recording_row = Some(s);
}
}
@ -382,7 +382,7 @@ fn compare_stream(
stream
.recordings
.entry(id.recording())
.or_insert_with(Recording::default)
.or_default()
.playback_row = Some(s);
}
}
@ -405,7 +405,7 @@ fn compare_stream(
stream
.recordings
.entry(id.recording())
.or_insert_with(Recording::default)
.or_default()
.integrity_row = true;
}
}

View File

@ -102,7 +102,7 @@ impl Value for SignalValue {
type Change = SignalChange;
fn apply(&mut self, c: &SignalChange) {
if self.states.len() < usize::try_from(c.new_state).unwrap() {
if self.states.len() < usize::from(c.new_state) {
self.states.resize(c.new_state as usize, 0);
}
@ -117,7 +117,7 @@ impl Value for SignalValue {
if c.old_state > 0 {
// remove from old state.
let i = usize::try_from(c.old_state).unwrap() - 1;
let i = usize::from(c.old_state) - 1;
assert!(
self.states.len() > i,
"no such old state: s={self:?} c={c:?}"

View File

@ -37,8 +37,7 @@ use crate::signal;
use base::clock::{self, Clocks};
use base::strutil::encode_size;
use base::{bail, err, Error};
// use failure::{bail, err, Error, ResultExt};
use fnv::{FnvHashMap, FnvHashSet};
use base::{FastHashMap, FastHashSet};
use hashlink::LinkedHashMap;
use itertools::Itertools;
use rusqlite::{named_params, params};
@ -180,7 +179,7 @@ impl std::fmt::Debug for VideoSampleEntryToInsert {
}
/// A row used in `list_recordings_by_time` and `list_recordings_by_id`.
#[derive(Copy, Clone, Debug)]
#[derive(Clone, Debug)]
pub struct ListRecordingsRow {
pub start: recording::Time,
pub video_sample_entry_id: i32,
@ -201,6 +200,7 @@ pub struct ListRecordingsRow {
/// (It's not included in the `recording_cover` index, so adding it to
/// `list_recordings_by_time` would be inefficient.)
pub prev_media_duration_and_runs: Option<(recording::Duration, i32)>,
pub end_reason: Option<String>,
}
/// A row used in `list_aggregated_recordings`.
@ -218,6 +218,7 @@ pub struct ListAggregatedRecordingsRow {
pub first_uncommitted: Option<i32>,
pub growing: bool,
pub has_trailing_zero: bool,
pub end_reason: Option<String>,
}
impl ListAggregatedRecordingsRow {
@ -242,6 +243,7 @@ impl ListAggregatedRecordingsRow {
},
growing,
has_trailing_zero: (row.flags & RecordingFlags::TrailingZero as i32) != 0,
end_reason: row.end_reason,
}
}
}
@ -302,6 +304,7 @@ impl RecordingToInsert {
open_id,
flags: self.flags | RecordingFlags::Uncommitted as i32,
prev_media_duration_and_runs: Some((self.prev_media_duration, self.prev_runs)),
end_reason: self.end_reason.clone(),
}
}
}
@ -325,7 +328,7 @@ pub struct SampleFileDir {
/// ids which are in the `garbage` database table (rather than `recording`) as of last commit
/// but may still exist on disk. These can't be safely removed from the database yet.
pub(crate) garbage_needs_unlink: FnvHashSet<CompositeId>,
pub(crate) garbage_needs_unlink: FastHashSet<CompositeId>,
/// ids which are in the `garbage` database table and are guaranteed to no longer exist on
/// disk (have been unlinked and the dir has been synced). These may be removed from the
@ -620,7 +623,7 @@ pub struct LockedDatabase {
streams_by_id: BTreeMap<i32, Stream>,
cameras_by_uuid: BTreeMap<Uuid, i32>, // values are ids.
video_sample_entries_by_id: BTreeMap<i32, Arc<VideoSampleEntry>>,
video_index_cache: RefCell<LinkedHashMap<i64, Box<[u8]>, fnv::FnvBuildHasher>>,
video_index_cache: RefCell<LinkedHashMap<i64, Box<[u8]>, base::RandomState>>,
on_flush: Vec<Box<dyn Fn() + Send>>,
}
@ -1010,7 +1013,7 @@ impl LockedDatabase {
};
let tx = self.conn.transaction()?;
let mut new_ranges =
FnvHashMap::with_capacity_and_hasher(self.streams_by_id.len(), Default::default());
FastHashMap::with_capacity_and_hasher(self.streams_by_id.len(), Default::default());
{
let mut stmt = tx.prepare_cached(UPDATE_STREAM_COUNTERS_SQL)?;
for (&stream_id, s) in &self.streams_by_id {
@ -1100,7 +1103,7 @@ impl LockedDatabase {
added_bytes: i64,
deleted_bytes: i64,
}
let mut dir_logs: FnvHashMap<i32, DirLog> = FnvHashMap::default();
let mut dir_logs: FastHashMap<i32, DirLog> = FastHashMap::default();
// Process delete_garbage.
for (&id, dir) in &mut self.sample_file_dirs_by_id {
@ -1214,7 +1217,7 @@ impl LockedDatabase {
/// Currently this only happens at startup (or during configuration), so this isn't a problem
/// in practice.
pub fn open_sample_file_dirs(&mut self, ids: &[i32]) -> Result<(), Error> {
let mut in_progress = FnvHashMap::with_capacity_and_hasher(ids.len(), Default::default());
let mut in_progress = FastHashMap::with_capacity_and_hasher(ids.len(), Default::default());
for &id in ids {
let e = in_progress.entry(id);
use ::std::collections::hash_map::Entry;
@ -1377,7 +1380,7 @@ impl LockedDatabase {
stream_id: i32,
desired_time: Range<recording::Time>,
forced_split: recording::Duration,
f: &mut dyn FnMut(&ListAggregatedRecordingsRow) -> Result<(), base::Error>,
f: &mut dyn FnMut(ListAggregatedRecordingsRow) -> Result<(), base::Error>,
) -> Result<(), base::Error> {
// Iterate, maintaining a map from a recording_id to the aggregated row for the latest
// batch of recordings from the run starting at that id. Runs can be split into multiple
@ -1411,8 +1414,7 @@ impl LockedDatabase {
|| new_dur >= forced_split;
if needs_flush {
// flush then start a new entry.
f(a)?;
*a = ListAggregatedRecordingsRow::from(row);
f(std::mem::replace(a, ListAggregatedRecordingsRow::from(row)))?;
} else {
// append.
if a.time.end != row.start {
@ -1451,6 +1453,7 @@ impl LockedDatabase {
}
a.growing = growing;
a.has_trailing_zero = has_trailing_zero;
a.end_reason = row.end_reason;
}
}
Entry::Vacant(e) => {
@ -1459,7 +1462,7 @@ impl LockedDatabase {
}
Ok(())
})?;
for a in aggs.values() {
for a in aggs.into_values() {
f(a)?;
}
Ok(())
@ -1837,7 +1840,7 @@ impl LockedDatabase {
uuid,
dir: Some(dir),
last_complete_open: Some(*o),
garbage_needs_unlink: FnvHashSet::default(),
garbage_needs_unlink: FastHashSet::default(),
garbage_unlinked: Vec::new(),
}),
Entry::Occupied(_) => bail!(Internal, msg("duplicate sample file dir id {id}")),
@ -2161,7 +2164,7 @@ impl LockedDatabase {
pub fn signals_by_id(&self) -> &BTreeMap<u32, signal::Signal> {
self.signal.signals_by_id()
}
pub fn signal_types_by_uuid(&self) -> &FnvHashMap<Uuid, signal::Type> {
pub fn signal_types_by_uuid(&self) -> &FastHashMap<Uuid, signal::Type> {
self.signal.types_by_uuid()
}
pub fn list_changes_by_time(

View File

@ -25,6 +25,7 @@ use std::ffi::CStr;
use std::fs;
use std::io::{Read, Write};
use std::ops::Range;
use std::os::fd::{AsFd, BorrowedFd};
use std::os::unix::io::{AsRawFd, RawFd};
use std::path::Path;
use std::sync::Arc;
@ -87,9 +88,9 @@ impl NixPath for CompositeIdPath {
#[derive(Debug)]
pub struct Fd(std::os::unix::io::RawFd);
impl std::os::unix::io::AsRawFd for Fd {
fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
self.0
impl AsFd for Fd {
fn as_fd(&self) -> std::os::unix::prelude::BorrowedFd<'_> {
unsafe { BorrowedFd::borrow_raw(self.0) }
}
}
@ -316,7 +317,7 @@ impl SampleFileDir {
pub(crate) fn opendir(&self) -> Result<nix::dir::Dir, nix::Error> {
nix::dir::Dir::openat(
self.fd.as_raw_fd(),
self.fd.as_fd().as_raw_fd(),
".",
OFlag::O_DIRECTORY | OFlag::O_RDONLY,
Mode::empty(),

View File

@ -22,7 +22,6 @@
use std::convert::TryFrom;
use std::future::Future;
use std::os::unix::prelude::AsRawFd;
use std::path::Path;
use std::{
ops::Range,
@ -352,7 +351,7 @@ impl ReaderInt {
map_len,
nix::sys::mman::ProtFlags::PROT_READ,
nix::sys::mman::MapFlags::MAP_SHARED,
file.as_raw_fd(),
Some(&file),
offset,
)
}

View File

@ -7,8 +7,8 @@
use crate::db::{self, CompositeId, SqlUuid};
use crate::json::GlobalConfig;
use crate::recording;
use base::FastHashSet;
use base::{bail, err, Error, ErrorKind, ResultExt as _};
use fnv::FnvHashSet;
use rusqlite::{named_params, params};
use std::ops::Range;
use uuid::Uuid;
@ -26,7 +26,8 @@ const LIST_RECORDINGS_BY_TIME_SQL: &str = r#"
recording.video_samples,
recording.video_sync_samples,
recording.video_sample_entry_id,
recording.open_id
recording.open_id,
recording.end_reason
from
recording
where
@ -51,6 +52,7 @@ const LIST_RECORDINGS_BY_ID_SQL: &str = r#"
recording.video_sync_samples,
recording.video_sample_entry_id,
recording.open_id,
recording.end_reason,
recording.prev_media_duration_90k,
recording.prev_runs
from
@ -158,11 +160,12 @@ fn list_recordings_inner(
video_sync_samples: row.get(8).err_kind(ErrorKind::Internal)?,
video_sample_entry_id: row.get(9).err_kind(ErrorKind::Internal)?,
open_id: row.get(10).err_kind(ErrorKind::Internal)?,
end_reason: row.get(11).err_kind(ErrorKind::Internal)?,
prev_media_duration_and_runs: match include_prev {
false => None,
true => Some((
recording::Duration(row.get(11).err_kind(ErrorKind::Internal)?),
row.get(12).err_kind(ErrorKind::Internal)?,
recording::Duration(row.get(12).err_kind(ErrorKind::Internal)?),
row.get(13).err_kind(ErrorKind::Internal)?,
)),
},
})?;
@ -422,8 +425,8 @@ pub(crate) fn get_range(
pub(crate) fn list_garbage(
conn: &rusqlite::Connection,
dir_id: i32,
) -> Result<FnvHashSet<CompositeId>, Error> {
let mut garbage = FnvHashSet::default();
) -> Result<FastHashSet<CompositeId>, Error> {
let mut garbage = FastHashSet::default();
let mut stmt =
conn.prepare_cached("select composite_id from garbage where sample_file_dir_id = ?")?;
let mut rows = stmt.query([&dir_id])?;

View File

@ -361,8 +361,8 @@ create table user_session (
-- A value indicating the reason for revocation, with optional additional
-- text detail. Enumeration values:
-- 0: logout link clicked (i.e. from within the session itself)
-- 1: obsoleted by a change in hashing algorithm (eg schema 5->6 upgrade)
-- 1: logout link clicked (i.e. from within the session itself)
-- 2: obsoleted by a change in hashing algorithm (eg schema 5->6 upgrade)
--
-- This might be extended for a variety of other reasons:
-- x: user revoked (while authenticated in another way)

View File

@ -8,8 +8,8 @@
use crate::json::{SignalConfig, SignalTypeConfig};
use crate::{coding, days};
use crate::{recording, SqlUuid};
use base::FastHashMap;
use base::{bail, err, Error};
use fnv::FnvHashMap;
use rusqlite::{params, Connection, Transaction};
use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet};
@ -25,7 +25,7 @@ pub(crate) struct State {
/// All types with known states. Note that currently there's no requirement an entry here
/// exists for every `type_` specified in a `Signal`, and there's an implied `0` (unknown)
/// state for every `Type`.
types_by_uuid: FnvHashMap<Uuid, Type>,
types_by_uuid: FastHashMap<Uuid, Type>,
/// All points in time.
/// Invariants, checked by `State::debug_assert_point_invariants`:
@ -691,8 +691,8 @@ impl State {
Ok(signals)
}
fn init_types(conn: &Connection) -> Result<FnvHashMap<Uuid, Type>, Error> {
let mut types = FnvHashMap::default();
fn init_types(conn: &Connection) -> Result<FastHashMap<Uuid, Type>, Error> {
let mut types = FastHashMap::default();
let mut stmt = conn.prepare(
r#"
select
@ -790,7 +790,7 @@ impl State {
pub fn signals_by_id(&self) -> &BTreeMap<u32, Signal> {
&self.signals_by_id
}
pub fn types_by_uuid(&self) -> &FnvHashMap<Uuid, Type> {
pub fn types_by_uuid(&self) -> &FastHashMap<Uuid, Type> {
&self.types_by_uuid
}

View File

@ -9,7 +9,7 @@ use crate::db;
use crate::dir;
use crate::writer;
use base::clock::Clocks;
use fnv::FnvHashMap;
use base::FastHashMap;
use std::env;
use std::sync::Arc;
use std::thread;
@ -47,7 +47,7 @@ pub fn init() {
pub struct TestDb<C: Clocks + Clone> {
pub db: Arc<db::Database<C>>,
pub dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
pub dirs_by_stream_id: Arc<FastHashMap<i32, Arc<dir::SampleFileDir>>>,
pub shutdown_tx: base::shutdown::Sender,
pub shutdown_rx: base::shutdown::Receiver,
pub syncer_channel: writer::SyncerChannel<::std::fs::File>,
@ -116,7 +116,7 @@ impl<C: Clocks + Clone> TestDb<C> {
.get()
.unwrap();
}
let mut dirs_by_stream_id = FnvHashMap::default();
let mut dirs_by_stream_id = FastHashMap::default();
dirs_by_stream_id.insert(TEST_STREAM_ID, dir);
let (shutdown_tx, shutdown_rx) = base::shutdown::channel();
let (syncer_channel, syncer_join) =

View File

@ -166,7 +166,7 @@ mod tests {
use crate::compare;
use crate::testutil;
use base::err;
use fnv::FnvHashMap;
use base::FastHashMap;
const BAD_ANAMORPHIC_VIDEO_SAMPLE_ENTRY: &[u8] = b"\x00\x00\x00\x84\x61\x76\x63\x31\x00\x00\
\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
@ -344,7 +344,7 @@ mod tests {
"#,
)?;
let mut rows = stmt.query(params![])?;
let mut pasp_by_id = FnvHashMap::default();
let mut pasp_by_id = FastHashMap::default();
while let Some(row) = rows.next()? {
let id: i32 = row.get(0)?;
let pasp_h_spacing: i32 = row.get(1)?;

View File

@ -308,7 +308,7 @@ fn verify_dir_contents(
params![],
|r| r.get(0),
)?;
let mut files = ::fnv::FnvHashSet::with_capacity_and_hasher(n as usize, Default::default());
let mut files = ::base::FastHashSet::with_capacity_and_hasher(n as usize, Default::default());
for e in dir.iter() {
let e = e?;
let f = e.file_name();

View File

@ -10,6 +10,7 @@ use crate::dir;
use crate::schema;
use base::Error;
use rusqlite::params;
use std::os::fd::AsFd as _;
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
use std::sync::Arc;
@ -71,9 +72,9 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
let from_path = super::UuidPath::from(sample_file_uuid.0);
let to_path = crate::dir::CompositeIdPath::from(id);
if let Err(e) = nix::fcntl::renameat(
Some(d.fd.as_raw_fd()),
Some(d.fd.as_fd().as_raw_fd()),
&from_path,
Some(d.fd.as_raw_fd()),
Some(d.fd.as_fd().as_raw_fd()),
&to_path,
) {
if e == nix::Error::ENOENT {

View File

@ -15,6 +15,7 @@ use nix::sys::stat::Mode;
use protobuf::Message;
use rusqlite::params;
use std::io::{Read, Write};
use std::os::fd::AsFd as _;
use std::os::unix::io::AsRawFd;
use tracing::info;
use uuid::Uuid;
@ -25,7 +26,12 @@ const FIXED_DIR_META_LEN: usize = 512;
fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool, Error> {
let tmp_path = cstr!("meta.tmp");
let meta_path = cstr!("meta");
let mut f = crate::fs::openat(dir.as_raw_fd(), meta_path, OFlag::O_RDONLY, Mode::empty())?;
let mut f = crate::fs::openat(
dir.as_fd().as_raw_fd(),
meta_path,
OFlag::O_RDONLY,
Mode::empty(),
)?;
let mut data = Vec::new();
f.read_to_end(&mut data)?;
if data.len() == FIXED_DIR_META_LEN {
@ -49,7 +55,7 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
);
}
let mut f = crate::fs::openat(
dir.as_raw_fd(),
dir.as_fd().as_raw_fd(),
tmp_path,
OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_WRONLY,
Mode::S_IRUSR | Mode::S_IWUSR,
@ -72,9 +78,9 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
f.sync_all()?;
nix::fcntl::renameat(
Some(dir.as_raw_fd()),
Some(dir.as_fd().as_raw_fd()),
tmp_path,
Some(dir.as_raw_fd()),
Some(dir.as_fd().as_raw_fd()),
meta_path,
)?;
Ok(true)
@ -89,7 +95,7 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
fn maybe_cleanup_garbage_uuids(dir: &dir::Fd) -> Result<bool, Error> {
let mut need_sync = false;
let mut dir2 = nix::dir::Dir::openat(
dir.as_raw_fd(),
dir.as_fd().as_raw_fd(),
".",
OFlag::O_DIRECTORY | OFlag::O_RDONLY,
Mode::empty(),
@ -105,7 +111,7 @@ fn maybe_cleanup_garbage_uuids(dir: &dir::Fd) -> Result<bool, Error> {
if Uuid::parse_str(f_str).is_ok() {
info!("removing leftover garbage file {}", f_str);
nix::unistd::unlinkat(
Some(dir.as_raw_fd()),
Some(dir.as_fd().as_raw_fd()),
f,
nix::unistd::UnlinkatFlags::NoRemoveDir,
)?;

View File

@ -2,9 +2,9 @@
// 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
use base::FastHashMap;
/// Upgrades a version 6 schema to a version 7 schema.
use base::{err, Error};
use fnv::FnvHashMap;
use rusqlite::{named_params, params};
use std::{convert::TryFrom, path::PathBuf};
use tracing::debug;
@ -133,7 +133,7 @@ fn copy_users(tx: &rusqlite::Transaction) -> Result<(), Error> {
}
fn copy_signal_types(tx: &rusqlite::Transaction) -> Result<(), Error> {
let mut types_ = FnvHashMap::default();
let mut types_ = FastHashMap::default();
let mut stmt = tx.prepare("select type_uuid, value, name from signal_type_enum")?;
let mut rows = stmt.query(params![])?;
while let Some(row) = rows.next()? {
@ -164,7 +164,7 @@ struct Signal {
}
fn copy_signals(tx: &rusqlite::Transaction) -> Result<(), Error> {
let mut signals = FnvHashMap::default();
let mut signals = FastHashMap::default();
// Read from signal table.
{

View File

@ -9,8 +9,8 @@ use crate::dir;
use crate::recording::{self, MAX_RECORDING_WALL_DURATION};
use base::clock::{self, Clocks};
use base::shutdown::ShutdownError;
use base::FastHashMap;
use base::{bail, err, Error};
use fnv::FnvHashMap;
use std::cmp::{self, Ordering};
use std::convert::TryFrom;
use std::io;
@ -294,7 +294,7 @@ impl<F: FileWriter> SyncerChannel<F> {
/// on opening.
fn list_files_to_abandon(
dir: &dir::SampleFileDir,
streams_to_next: FnvHashMap<i32, i32>,
streams_to_next: FastHashMap<i32, i32>,
) -> Result<Vec<CompositeId>, Error> {
let mut v = Vec::new();
let mut d = dir.opendir()?;
@ -330,7 +330,7 @@ impl<C: Clocks + Clone> Syncer<C, Arc<dir::SampleFileDir>> {
// Abandon files.
// First, get a list of the streams in question.
let streams_to_next: FnvHashMap<_, _> = l
let streams_to_next: FastHashMap<_, _> = l
.streams_by_id()
.iter()
.filter_map(|(&k, v)| {

View File

@ -4,14 +4,14 @@
//! UI bundled (compiled/linked) into the executable for single-file deployment.
use fnv::FnvHashMap;
use base::FastHashMap;
use http::{header, HeaderMap, HeaderValue};
use std::io::Read;
use std::sync::OnceLock;
use crate::body::{BoxedError, Chunk};
pub struct Ui(FnvHashMap<&'static str, FileSet>);
pub struct Ui(FastHashMap<&'static str, FileSet>);
/// A file as passed in from `build.rs`.
struct BuildFile {

View File

@ -181,14 +181,16 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
);
}
let stream_change = &mut change.streams[i];
stream_change.config.mode = (if stream.record {
(if stream.record {
db::json::STREAM_MODE_RECORD
} else {
""
})
.to_owned();
.clone_into(&mut stream_change.config.mode);
stream_change.config.url = parse_stream_url(type_, &stream.url)?;
stream_change.config.rtsp_transport = stream.rtsp_transport.to_owned();
stream
.rtsp_transport
.clone_into(&mut stream_change.config.rtsp_transport);
stream_change.sample_file_dir_id = stream.sample_file_dir_id;
stream_change.config.flush_if_sec = if stream.flush_if_sec.is_empty() {
0

View File

@ -6,6 +6,7 @@
use base::clock::{self, Clocks};
use base::{bail, err, Error};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use bpaf::Bpaf;
use db::auth::SessionFlag;
use std::io::Write as _;
@ -94,7 +95,9 @@ pub fn run(args: Args) -> Result<i32, Error> {
permissions,
)?;
let mut encoded = [0u8; 64];
base64::encode_config_slice(sid, base64::STANDARD_NO_PAD, &mut encoded);
STANDARD_NO_PAD
.encode_slice(sid, &mut encoded)
.expect("base64 encode should succeed");
let encoded = std::str::from_utf8(&encoded[..]).expect("base64 is valid UTF-8");
if let Some(ref p) = args.curl_cookie_jar {

View File

@ -72,7 +72,7 @@ fn open_conn(db_dir: &Path, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connec
mode,
rusqlite::version()
);
let conn = rusqlite::Connection::open_with_flags(
let conn = rusqlite::Connection::open_with_flags_and_vfs(
db_path,
match mode {
OpenMode::ReadOnly => rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
@ -81,9 +81,17 @@ fn open_conn(db_dir: &Path, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connec
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
},
} |
// rusqlite::Connection is not Sync, so there's no reason to tell SQLite3 to use the
// `rusqlite::Connection` is not Sync, so there's no reason to tell SQLite3 to use the
// serialized threading mode.
rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
// In read/write mode, Moonfire holds a directory lock for its entire operation, as
// described above. There's then no point in SQLite releasing its lock after each
// transaction and reacquiring it, or in using shared memory for the wal-index.
// See the following page: <https://www.sqlite.org/vfs.html>
match mode {
OpenMode::ReadOnly => "unix",
_ => "unix-excl",
},
)?;
Ok((dir, conn))
}

View File

@ -44,7 +44,7 @@ pub struct ConfigFile {
#[serde(rename_all = "camelCase", untagged)]
pub enum UiDir {
FromFilesystem(PathBuf),
Bundled(BundledUi),
Bundled(#[allow(unused)] BundledUi),
}
impl Default for UiDir {
@ -111,6 +111,10 @@ pub enum AddressConfig {
/// Unix socket path such as `/var/lib/moonfire-nvr/sock`.
Unix(PathBuf),
// TODO: SystemdFileDescriptorName(String), see
// https://www.freedesktop.org/software/systemd/man/systemd.socket.html
/// `systemd` socket activation.
///
/// See [systemd.socket(5) manual
/// page](https://www.freedesktop.org/software/systemd/man/systemd.socket.html).
Systemd(#[cfg_attr(not(target_os = "linux"), allow(unused))] String),
}

View File

@ -7,11 +7,12 @@ use crate::web;
use crate::web::accept::Listener;
use base::clock;
use base::err;
use base::FastHashMap;
use base::{bail, Error};
use bpaf::Bpaf;
use db::{dir, writer};
use fnv::FnvHashMap;
use hyper::service::{make_service_fn, service_fn};
use itertools::Itertools;
use retina::client::SessionGroup;
use std::net::SocketAddr;
use std::path::Path;
@ -22,6 +23,9 @@ use tokio::signal::unix::{signal, SignalKind};
use tracing::error;
use tracing::{info, warn};
#[cfg(target_os = "linux")]
use libsystemd::daemon::{notify, NotifyState};
use self::config::ConfigFile;
pub mod config;
@ -129,9 +133,57 @@ struct Syncer {
join: thread::JoinHandle<()>,
}
#[cfg(target_os = "linux")]
fn get_preopened_sockets() -> Result<FastHashMap<String, Listener>, Error> {
use libsystemd::activation::IsType as _;
use std::os::fd::{FromRawFd, IntoRawFd};
// `receive_descriptors_with_names` errors out if not running under systemd or not using socket
// activation.
if std::env::var_os("LISTEN_FDS").is_none() {
info!("no LISTEN_FDs");
return Ok(FastHashMap::default());
}
let sockets = libsystemd::activation::receive_descriptors_with_names(false)
.map_err(|e| err!(Unknown, source(e), msg("unable to receive systemd sockets")))?;
sockets
.into_iter()
.map(|(fd, name)| {
if fd.is_unix() {
// SAFETY: yes, it's a socket we own.
let l = unsafe { std::os::unix::net::UnixListener::from_raw_fd(fd.into_raw_fd()) };
l.set_nonblocking(true)?;
Ok(Some((
name,
Listener::Unix(tokio::net::UnixListener::from_std(l)?),
)))
} else if fd.is_inet() {
// SAFETY: yes, it's a socket we own.
let l = unsafe { std::net::TcpListener::from_raw_fd(fd.into_raw_fd()) };
l.set_nonblocking(true)?;
Ok(Some((
name,
Listener::Tcp(tokio::net::TcpListener::from_std(l)?),
)))
} else {
warn!("ignoring systemd socket {name:?} which is not unix or inet");
Ok(None)
}
})
.filter_map(Result::transpose)
.collect()
}
#[cfg(not(target_os = "linux"))]
fn get_preopened_sockets() -> Result<FastHashMap<String, Listener>, Error> {
Ok(FastHashMap::default())
}
fn read_config(path: &Path) -> Result<ConfigFile, Error> {
let config = std::fs::read(path)?;
let config = toml::from_slice(&config).map_err(|e| err!(InvalidArgument, source(e)))?;
let config = std::str::from_utf8(&config).map_err(|e| err!(InvalidArgument, source(e)))?;
let config = toml::from_str(config).map_err(|e| err!(InvalidArgument, source(e)))?;
Ok(config)
}
@ -214,7 +266,13 @@ fn prepare_unix_socket(p: &Path) {
let _ = nix::unistd::unlink(p);
}
fn make_listener(addr: &config::AddressConfig) -> Result<Listener, Error> {
fn make_listener(
addr: &config::AddressConfig,
#[cfg_attr(not(target_os = "linux"), allow(unused))] preopened: &mut FastHashMap<
String,
Listener,
>,
) -> Result<Listener, Error> {
let sa: SocketAddr = match addr {
config::AddressConfig::Ipv4(a) => (*a).into(),
config::AddressConfig::Ipv6(a) => (*a).into(),
@ -224,6 +282,23 @@ fn make_listener(addr: &config::AddressConfig) -> Result<Listener, Error> {
|e| err!(e, msg("unable bind Unix socket {}", p.display())),
)?));
}
#[cfg(target_os = "linux")]
config::AddressConfig::Systemd(n) => {
return preopened.remove(n).ok_or_else(|| {
err!(
NotFound,
msg(
"can't find systemd socket named {}; available sockets are: {}",
n,
preopened.keys().join(", ")
)
)
});
}
#[cfg(not(target_os = "linux"))]
config::AddressConfig::Systemd(_) => {
bail!(Unimplemented, msg("systemd sockets are Linux-only"))
}
};
// Go through std::net::TcpListener to avoid needing async. That's there for DNS resolution,
@ -267,11 +342,11 @@ async fn inner(
// Start a streamer for each stream.
let mut streamers = Vec::new();
let mut session_groups_by_camera: FnvHashMap<i32, Arc<retina::client::SessionGroup>> =
FnvHashMap::default();
let mut session_groups_by_camera: FastHashMap<i32, Arc<retina::client::SessionGroup>> =
FastHashMap::default();
let syncers = if !read_only {
let l = db.lock();
let mut dirs = FnvHashMap::with_capacity_and_hasher(
let mut dirs = FastHashMap::with_capacity_and_hasher(
l.sample_file_dirs_by_id().len(),
Default::default(),
);
@ -303,7 +378,7 @@ async fn inner(
// Then, with the lock dropped, create syncers.
drop(l);
let mut syncers = FnvHashMap::with_capacity_and_hasher(dirs.len(), Default::default());
let mut syncers = FastHashMap::with_capacity_and_hasher(dirs.len(), Default::default());
for (id, dir) in dirs.drain() {
let (channel, join) = writer::start_syncer(db.clone(), shutdown_rx.clone(), id)?;
syncers.insert(id, Syncer { dir, channel, join });
@ -372,6 +447,7 @@ async fn inner(
// Start the web interface(s).
let own_euid = nix::unistd::Uid::effective();
let mut preopened = get_preopened_sockets()?;
let web_handles: Result<Vec<_>, Error> = config
.binds
.iter()
@ -394,17 +470,37 @@ async fn inner(
move |req| Arc::clone(&svc).serve(req, conn_data)
}))
});
let listener = make_listener(&b.address)?;
let listener = make_listener(&b.address, &mut preopened)?;
let server = ::hyper::Server::builder(listener).serve(make_svc);
let server = server.with_graceful_shutdown(shutdown_rx.future());
Ok(tokio::spawn(server))
})
.collect();
let web_handles = web_handles?;
if !preopened.is_empty() {
warn!(
"ignoring systemd sockets not referenced in config: {}",
preopened.keys().join(", ")
);
}
#[cfg(target_os = "linux")]
{
if let Err(err) = notify(false, &[NotifyState::Ready]) {
tracing::warn!(%err, "unable to notify systemd on ready");
}
}
info!("Ready to serve HTTP requests");
shutdown_rx.as_future().await;
#[cfg(target_os = "linux")]
{
if let Err(err) = notify(false, &[NotifyState::Stopping]) {
tracing::warn!(%err, "unable to notify systemd on stopping");
}
}
info!("Shutting down streamers and syncers.");
tokio::task::spawn_blocking({
let db = db.clone();

View File

@ -21,6 +21,8 @@
use base::{bail, err, Error};
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
use db::VideoSampleEntryToInsert;
use h264_reader::nal::Nal;
use pretty_hex::PrettyHex as _;
use std::convert::TryFrom;
// For certain common sub stream anamorphic resolutions, add a pixel aspect ratio box.
@ -60,8 +62,84 @@ fn default_pixel_aspect_ratio(width: u16, height: u16) -> (u16, u16) {
}
}
/// Parses the `AvcDecoderConfigurationRecord` in the "extra data".
pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
/// `h264_reader::rbsp::BitRead` impl that does not care about extra trailing data.
///
/// Some (Reolink) cameras appear to have a stray extra byte at the end. Follow the lead of most
/// other RTSP implementations in tolerating this.
#[derive(Debug)]
struct TolerantBitReader<R> {
inner: R,
}
impl<R: h264_reader::rbsp::BitRead> h264_reader::rbsp::BitRead for TolerantBitReader<R> {
fn read_ue(&mut self, name: &'static str) -> Result<u32, h264_reader::rbsp::BitReaderError> {
self.inner.read_ue(name)
}
fn read_se(&mut self, name: &'static str) -> Result<i32, h264_reader::rbsp::BitReaderError> {
self.inner.read_se(name)
}
fn read_bool(&mut self, name: &'static str) -> Result<bool, h264_reader::rbsp::BitReaderError> {
self.inner.read_bool(name)
}
fn read_u8(
&mut self,
bit_count: u32,
name: &'static str,
) -> Result<u8, h264_reader::rbsp::BitReaderError> {
self.inner.read_u8(bit_count, name)
}
fn read_u16(
&mut self,
bit_count: u32,
name: &'static str,
) -> Result<u16, h264_reader::rbsp::BitReaderError> {
self.inner.read_u16(bit_count, name)
}
fn read_u32(
&mut self,
bit_count: u32,
name: &'static str,
) -> Result<u32, h264_reader::rbsp::BitReaderError> {
self.inner.read_u32(bit_count, name)
}
fn read_i32(
&mut self,
bit_count: u32,
name: &'static str,
) -> Result<i32, h264_reader::rbsp::BitReaderError> {
self.inner.read_i32(bit_count, name)
}
fn has_more_rbsp_data(
&mut self,
name: &'static str,
) -> Result<bool, h264_reader::rbsp::BitReaderError> {
self.inner.has_more_rbsp_data(name)
}
fn finish_rbsp(self) -> Result<(), h264_reader::rbsp::BitReaderError> {
match self.inner.finish_rbsp() {
Ok(()) => Ok(()),
Err(h264_reader::rbsp::BitReaderError::RemainingData) => {
tracing::debug!("extra data at end of NAL unit");
Ok(())
}
Err(e) => Err(e),
}
}
fn finish_sei_payload(self) -> Result<(), h264_reader::rbsp::BitReaderError> {
self.inner.finish_sei_payload()
}
}
fn parse_extra_data_inner(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
let avcc =
h264_reader::avcc::AvcDecoderConfigurationRecord::try_from(extradata).map_err(|e| {
err!(
@ -72,9 +150,39 @@ pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Er
if avcc.num_of_sequence_parameter_sets() != 1 {
bail!(Unimplemented, msg("multiple SPSs!"));
}
let ctx = avcc
.create_context()
.map_err(|e| err!(Unknown, msg("can't load SPS+PPS: {:?}", e)))?;
// This logic is essentially copied from
// `h264_reader::avcc::AvcDecoderConfigurationRecord::create_context` but
// using our `TolerantBitReader` wrapper.
let mut ctx = h264_reader::Context::new();
for sps in avcc.sequence_parameter_sets() {
let sps = h264_reader::nal::RefNal::new(
sps.map_err(|e| err!(InvalidArgument, msg("bad SPS: {e:?}")))?,
&[],
true,
);
let sps = h264_reader::nal::sps::SeqParameterSet::from_bits(TolerantBitReader {
inner: sps.rbsp_bits(),
})
.map_err(|e| err!(InvalidArgument, msg("bad SPS: {e:?}")))?;
ctx.put_seq_param_set(sps);
}
for pps in avcc.picture_parameter_sets() {
let pps = h264_reader::nal::RefNal::new(
pps.map_err(|e| err!(InvalidArgument, msg("bad PPS: {e:?}")))?,
&[],
true,
);
let pps = h264_reader::nal::pps::PicParameterSet::from_bits(
&ctx,
TolerantBitReader {
inner: pps.rbsp_bits(),
},
)
.map_err(|e| err!(InvalidArgument, msg("bad PPS: {e:?}")))?;
ctx.put_pic_param_set(pps);
}
let sps = ctx
.sps_by_id(h264_reader::nal::pps::ParamSetId::from_u32(0).unwrap())
.ok_or_else(|| err!(Unimplemented, msg("no SPS 0")))?;
@ -175,17 +283,52 @@ pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Er
})
}
/// Parses the `AvcDecoderConfigurationRecord` in the "extra data".
pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
parse_extra_data_inner(extradata).map_err(|e| {
err!(
e,
msg(
"can't parse AvcDecoderRecord {}",
extradata.hex_conf(pretty_hex::HexConfig {
width: 0,
group: 0,
chunk: 0,
..Default::default()
})
)
)
})
}
#[cfg(test)]
mod tests {
use db::testutil;
#[rustfmt::skip]
const AVC_DECODER_CONFIG_TEST_INPUT: [u8; 38] = [
0x01, 0x4d, 0x00, 0x1f, 0xff, 0xe1, 0x00, 0x17,
0x01, 0x4d, 0x00, 0x1f, 0xff,
0xe1, 0x00, 0x17, // 1 SPS, length 0x17
0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80,
0x2d, 0xff, 0x35, 0x01, 0x01, 0x01, 0x40, 0x00,
0x00, 0xfa, 0x00, 0x00, 0x1d, 0x4c, 0x01,
0x01, 0x00, 0x04, // 1 PPS, length 0x04
0x68, 0xee, 0x3c, 0x80,
];
#[rustfmt::skip]
const AVC_DECODER_CONFIG_TEST_INPUT_WITH_TRAILING_GARBAGE: [u8; 40] = [
0x01, 0x4d, 0x00, 0x1f, 0xff,
0xe1, 0x00, 0x18, // 1 SPS, length 0x18
0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80,
0x2d, 0xff, 0x35, 0x01, 0x01, 0x01, 0x40, 0x00,
0x00, 0xfa, 0x00, 0x00, 0x1d, 0x4c, 0x01, 0x01,
0x00, 0x04, 0x68, 0xee, 0x3c, 0x80,
0x01, 0x00, 0x04, // 1 PPS, length 0x05
0x68, 0xee, 0x3c, 0x80, 0x80,
];
#[rustfmt::skip]
@ -232,4 +375,9 @@ mod tests {
assert_eq!(Ratio::new(h * h_spacing, w * v_spacing), Ratio::new(9, 16));
}
}
#[test]
fn extra_sps_data() {
super::parse_extra_data(&AVC_DECODER_CONFIG_TEST_INPUT_WITH_TRAILING_GARBAGE).unwrap();
}
}

View File

@ -470,6 +470,7 @@ pub struct Recording {
pub video_sample_entry_id: i32,
pub start_id: i32,
pub open_id: u32,
pub run_start_id: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_uncommitted: Option<i32>,
@ -482,6 +483,9 @@ pub struct Recording {
#[serde(skip_serializing_if = "Not::not")]
pub has_trailing_zero: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_reason: Option<String>,
}
#[derive(Debug, Serialize)]

View File

@ -927,7 +927,7 @@ impl FileBuilder {
pub fn append(
&mut self,
db: &db::LockedDatabase,
row: db::ListRecordingsRow,
row: &db::ListRecordingsRow,
rel_media_range_90k: Range<i32>,
start_at_key: bool,
) -> Result<(), Error> {
@ -985,7 +985,7 @@ impl FileBuilder {
pub fn build(
mut self,
db: Arc<db::Database>,
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
dirs_by_stream_id: Arc<::base::FastHashMap<i32, Arc<dir::SampleFileDir>>>,
) -> Result<File, Error> {
let mut max_end = None;
let mut etag = blake3::Hasher::new();
@ -1777,7 +1777,7 @@ impl BodyState {
struct FileInner {
db: Arc<db::Database>,
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
dirs_by_stream_id: Arc<::base::FastHashMap<i32, Arc<dir::SampleFileDir>>>,
segments: Vec<Segment>,
slices: Slices<Slice>,
buf: Vec<u8>,
@ -2364,7 +2364,7 @@ mod tests {
"skip_90k={skip_90k} shorten_90k={shorten_90k} r={r:?}"
);
builder
.append(&db, r, skip_90k..d - shorten_90k, true)
.append(&db, &r, skip_90k..d - shorten_90k, true)
.unwrap();
Ok(())
})
@ -2492,7 +2492,7 @@ mod tests {
};
duration_so_far += row.media_duration_90k;
builder
.append(&db.db.lock(), row, d_start..d_end, start_at_key)
.append(&db.db.lock(), &row, d_start..d_end, start_at_key)
.unwrap();
}
builder.build(db.db.clone(), db.dirs_by_stream_id.clone())

View File

@ -145,7 +145,7 @@ where
// (from the preceding slice) the start of its range.
let (i, slice_start) = match self.slices.binary_search_by_key(&range.start, |s| s.end()) {
Ok(i) => (i + 1, self.slices[i].end()), // desired start == slice i's end; first is i+1!
Err(i) if i == 0 => (0, 0), // desired start < slice 0's end; first is 0.
Err(0) => (0, 0), // desired start < slice 0's end; first is 0.
Err(i) => (i, self.slices[i - 1].end()), // desired start < slice i's end; first is i.
};

View File

@ -111,8 +111,8 @@ impl Service {
let mut rows = 0;
db.list_recordings_by_id(stream_id, live.recording..live.recording + 1, &mut |r| {
rows += 1;
builder.append(&db, &r, live.media_off_90k.clone(), start_at_key)?;
row = Some(r);
builder.append(&db, r, live.media_off_90k.clone(), start_at_key)?;
Ok(())
})?;
}

View File

@ -20,13 +20,13 @@ use crate::mp4;
use crate::web::static_file::Ui;
use base::err;
use base::Error;
use base::FastHashMap;
use base::ResultExt;
use base::{bail, clock::Clocks, ErrorKind};
use core::borrow::Borrow;
use core::str::FromStr;
use db::dir::SampleFileDir;
use db::{auth, recording};
use fnv::FnvHashMap;
use http::header::{self, HeaderValue};
use http::{status::StatusCode, Request, Response};
use hyper::body::Bytes;
@ -37,26 +37,6 @@ use tracing::Instrument;
use url::form_urlencoded;
use uuid::Uuid;
/// An HTTP error response.
/// This is a thin wrapper over the hyper response type; it doesn't even verify
/// that the response actually uses a non-2xx status code. Its purpose is to
/// allow automatic conversion from `base::Error`. Rust's orphan rule prevents
/// this crate from defining a direct conversion from `base::Error` to
/// `hyper::Response`.
struct HttpError(Response<Body>);
impl From<Response<Body>> for HttpError {
fn from(response: Response<Body>) -> Self {
HttpError(response)
}
}
impl From<base::Error> for HttpError {
fn from(err: base::Error) -> Self {
HttpError(from_base_error(&err))
}
}
fn plain_response<B: Into<Body>>(status: http::StatusCode, body: B) -> Response<Body> {
Response::builder()
.status(status)
@ -172,7 +152,7 @@ pub struct Config<'a> {
pub struct Service {
db: Arc<db::Database>,
ui: Ui,
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
dirs_by_stream_id: Arc<FastHashMap<i32, Arc<SampleFileDir>>>,
time_zone_name: String,
allow_unauthenticated_permissions: Option<db::Permissions>,
trust_forward_hdrs: bool,
@ -199,7 +179,7 @@ impl Service {
let dirs_by_stream_id = {
let l = config.db.lock();
let mut d =
FnvHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
FastHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
for (&id, s) in l.streams_by_id().iter() {
let dir_id = match s.sample_file_dir_id {
Some(d) => d,
@ -496,6 +476,7 @@ impl Service {
} else {
Some(end)
},
run_start_id: row.run_start_id,
start_time_90k: row.time.start.0,
end_time_90k: row.time.end.0,
sample_file_bytes: row.sample_file_bytes,
@ -505,6 +486,7 @@ impl Service {
video_sample_entry_id: row.video_sample_entry_id,
growing: row.growing,
has_trailing_zero: row.has_trailing_zero,
end_reason: row.end_reason.clone(),
});
if !out
.video_sample_entries

View File

@ -5,6 +5,7 @@
//! Session management: `/api/login` and `/api/logout`.
use base::{bail, ErrorKind, ResultExt};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use db::auth;
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
use memchr::memchr;
@ -124,7 +125,7 @@ impl Service {
fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
let mut cookie = String::with_capacity(128);
cookie.push_str("s=");
base64::encode_config_buf(sid, base64::STANDARD_NO_PAD, &mut cookie);
STANDARD_NO_PAD.encode_string(sid, &mut cookie);
use auth::SessionFlag;
if (flags & SessionFlag::HttpOnly as i32) != 0 {
cookie.push_str("; HttpOnly");
@ -143,8 +144,8 @@ fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
#[cfg(test)]
mod tests {
use base::FastHashMap;
use db::testutil;
use fnv::FnvHashMap;
use tracing::info;
use crate::web::tests::Server;
@ -162,7 +163,7 @@ mod tests {
let resp = cli.post(&login_url).send().await.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
let mut p = FnvHashMap::default();
let mut p = FastHashMap::default();
p.insert("username", "slamb");
p.insert("password", "asdf");
let resp = cli.post(&login_url).json(&p).send().await.unwrap();
@ -189,7 +190,7 @@ mod tests {
testutil::init();
let s = Server::new(None);
let cli = reqwest::Client::new();
let mut p = FnvHashMap::default();
let mut p = FastHashMap::default();
p.insert("username", "slamb");
p.insert("password", "hunter2");
let resp = cli
@ -238,7 +239,7 @@ mod tests {
.get("csrf")
.unwrap()
.as_str();
let mut p = FnvHashMap::default();
let mut p = FastHashMap::default();
p.insert("csrf", csrf);
let resp = cli
.post(&format!("{}/api/logout", &s.base_url))

View File

@ -74,7 +74,7 @@ impl Ui {
);
hdrs.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?;
Ok(http_serve::serve(e, &req))
Ok(http_serve::serve(e, req))
}
#[cfg(feature = "bundled-ui")]
Ui::Bundled(ui) => {

View File

@ -39,6 +39,11 @@ impl Service {
bail!(PermissionDenied, msg("view_video required"));
}
let (stream_id, camera_name);
// False positive: on Rust 1.78.0, clippy erroneously suggests calling `clone_from` on the
// uninitialized `camera_name`.
// Apparently fixed in rustc 1.80.0-nightly (ada5e2c7b 2024-05-31).
#[allow(clippy::assigning_clones)]
{
let db = self.db.lock();
let camera = db
@ -136,7 +141,7 @@ impl Service {
r.wall_duration_90k,
r.media_duration_90k,
);
builder.append(&db, r, mr, true)?;
builder.append(&db, &r, mr, true)?;
} else {
trace!("...skipping recording {} wall dur {}", r.id, wd);
}

5
ui/.gitignore vendored
View File

@ -9,9 +9,12 @@
# testing
/coverage
# production
# production, current path
/build
# production, old path
/dist
# misc
.DS_Store
.env.local

View File

@ -11,24 +11,24 @@
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/favicons/apple-touch-icon-94a09b5d2ddb5af47.png"
href="favicons/apple-touch-icon-94a09b5d2ddb5af47.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicons/favicon-32x32-ab95901a9e0d040e2.png"
href="favicons/favicon-32x32-ab95901a9e0d040e2.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicons/favicon-16x16-b16b3f2883aacf9f1.png"
href="favicons/favicon-16x16-b16b3f2883aacf9f1.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<link rel="manifest" href="site.webmanifest" />
<link
rel="mask-icon"
href="%PUBLIC_URL%/favicons/safari-pinned-tab-9792c2c82f04639f8.svg"
href="favicons/safari-pinned-tab-9792c2c82f04639f8.svg"
color="#e04e1b"
/>
<meta name="theme-color" content="#e04e1b" />
@ -42,5 +42,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" style="display: flex; flex-direction: column"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

32821
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,43 +2,57 @@
"name": "ui",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@fontsource/roboto": "^4.5.3",
"@mui/icons-material": "^5.10.6",
"@mui/lab": "^5.0.0-alpha.102",
"@mui/material": "^5.10.8",
"@mui/x-date-pickers": "^5.0.3",
"@react-hook/resize-observer": "^1.2.5",
"@types/node": "^18.8.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"date-fns": "^2.28.0",
"date-fns-tz": "^1.3.0",
"gzipper": "^7.0.0",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^4.5.8",
"@mui/icons-material": "^5.15.15",
"@mui/lab": "5.0.0-alpha.170",
"@mui/material": "^5.15.15",
"@mui/x-date-pickers": "^6.19.8",
"@react-hook/resize-observer": "^1.2.6",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.41.5",
"react-hook-form-mui": "^5.12.3",
"react-router-dom": "^6.2.2",
"react-scripts": "^5.0.0",
"typescript": "^4.9.4"
"react-hook-form": "^7.51.2",
"react-hook-form-mui": "^6.8.0",
"react-router-dom": "^6.22.3"
},
"scripts": {
"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"
"check-format": "prettier --check --ignore-path .gitignore .",
"dev": "vite",
"build": "tsc && vite build",
"format": "prettier --write --ignore-path .gitignore .",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"eslint:recommended",
"plugin:vitest/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {
"no-undef": "off"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-restricted-imports": [
"error",
@ -50,29 +64,52 @@
"name": "@mui/icons-material",
"message": "Please use the 'import MenuIcon from \"material-ui/icons/Menu\";' style instead; see https://material-ui.com/guides/minimizing-bundle-size/#option-1"
}
]
],
"no-unused-vars": [
"error",
{
"args": "none"
}
],
"react/no-unescaped-entities": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/dom": "^8.11.3",
"@testing-library/jest-dom": "^5.16.2",
"@babel/core": "^7.24.4",
"@babel/preset-env": "^7.24.4",
"@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1",
"@swc/core": "^1.4.12",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.2.5",
"http-proxy-middleware": "^2.0.4",
"msw": "^0.49.2",
"prettier": "^2.6.0"
"@testing-library/user-event": "^14.5.2",
"@types/node": "^18.19.29",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-legacy": "^5.3.2",
"@vitejs/plugin-react-swc": "^3.6.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-vitest": "^0.3.26",
"http-proxy-middleware": "^2.0.6",
"jsdom": "^24.0.0",
"msw": "^2.2.13",
"prettier": "^2.8.8",
"terser": "^5.30.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^5.2.8",
"vite-plugin-compression": "^0.5.1",
"vitest": "^1.4.0"
}
}

8490
ui/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,18 @@
import { screen } from "@testing-library/react";
import App from "./App";
import { renderWithCtx } from "./testutil";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { beforeAll, afterAll, afterEach, expect, test } from "vitest";
const server = setupServer(
http.get("/api/", () => {
return HttpResponse.text("server error", { status: 503 });
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("instantiate", async () => {
renderWithCtx(<App />);

View File

@ -19,25 +19,16 @@
*/
import Container from "@mui/material/Container";
import React, { useEffect, useReducer, useState } from "react";
import React, { useEffect, useState } from "react";
import * as api from "./api";
import MoonfireMenu from "./AppMenu";
import Login from "./Login";
import { useSnackbars } from "./snackbars";
import ListActivity from "./List";
import AppBar from "@mui/material/AppBar";
import { Routes, Route, Link, Navigate } from "react-router-dom";
import { Routes, Route, Navigate } from "react-router-dom";
import LiveActivity from "./Live";
import UsersActivity from "./Users";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import ListIcon from "@mui/icons-material/List";
import PeopleIcon from "@mui/icons-material/People";
import Videocam from "@mui/icons-material/Videocam";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChangePassword from "./ChangePassword";
import Header from "./components/Header";
export type LoginState =
| "unknown"
@ -52,7 +43,6 @@ export interface FrameProps {
}
function App() {
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
const [fetchSeq, setFetchSeq] = useState(0);
@ -122,67 +112,14 @@ function App() {
const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
return (
<>
<AppBar position="static">
<MoonfireMenu
loginState={loginState}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
changePassword={() => setChangePasswordOpen(true)}
menuClick={toggleShowMenu}
activityMenuPart={activityMenuPart}
/>
</AppBar>
<Drawer
variant="temporary"
open={showMenu}
onClose={toggleShowMenu}
ModalProps={{
keepMounted: true,
}}
>
<List>
<ListItem
button
key="list"
onClick={toggleShowMenu}
component={Link}
to="/"
>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List view" />
</ListItem>
<ListItem
button
key="live"
onClick={toggleShowMenu}
component={Link}
to="/live"
>
<ListItemIcon>
<Videocam />
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItem>
{toplevel?.permissions.adminUsers && (
<ListItem
button
key="users"
onClick={toggleShowMenu}
component={Link}
to="/users"
>
<ListItemIcon>
<PeopleIcon />
</ListItemIcon>
<ListItemText primary="Users" />
</ListItem>
)}
</List>
</Drawer>
<Header
loginState={loginState}
logout={logout}
setChangePasswordOpen={setChangePasswordOpen}
activityMenuPart={activityMenuPart}
setLoginState={setLoginState}
toplevel={toplevel}
/>
<Login
onSuccess={onLoginSuccess}
open={

View File

@ -14,6 +14,11 @@ import MenuIcon from "@mui/icons-material/Menu";
import React from "react";
import { LoginState } from "./App";
import Box from "@mui/material/Box";
import { CurrentMode, useThemeMode } from "./components/ThemeMode";
import Brightness2 from "@mui/icons-material/Brightness2";
import Brightness7 from "@mui/icons-material/Brightness7";
import BrightnessAuto from "@mui/icons-material/BrightnessAuto";
import Tooltip from "@mui/material/Tooltip";
interface Props {
loginState: LoginState;
@ -26,6 +31,7 @@ interface Props {
// https://material-ui.com/components/app-bar/
function MoonfireMenu(props: Props) {
const { choosenTheme, changeTheme } = useThemeMode();
const theme = useTheme();
const [accountMenuAnchor, setAccountMenuAnchor] =
React.useState<null | HTMLElement>(null);
@ -69,6 +75,17 @@ function MoonfireMenu(props: Props) {
{props.activityMenuPart}
</Box>
)}
<Tooltip title="Toggle theme">
<IconButton onClick={changeTheme} color="inherit" size="small">
{choosenTheme === CurrentMode.Light ? (
<Brightness7 />
) : choosenTheme === CurrentMode.Dark ? (
<Brightness2 />
) : (
<BrightnessAuto />
)}
</IconButton>
</Tooltip>
{props.loginState !== "unknown" && props.loginState !== "logged-in" && (
<Button color="inherit" onClick={props.requestLogin}>
Log in

View File

@ -62,9 +62,9 @@ const ChangePassword = ({ user, open, handleClose }: Props) => {
if (loading === null) {
return;
}
let abort = new AbortController();
const abort = new AbortController();
const send = async (signal: AbortSignal) => {
let response = await api.updateUser(
const response = await api.updateUser(
loading.userId,
{
csrf: loading.csrf,

View File

@ -4,9 +4,10 @@
import { render, screen } from "@testing-library/react";
import ErrorBoundary from "./ErrorBoundary";
import { expect, test } from "vitest";
const ThrowsLiteralComponent = () => {
throw "simple string error"; // eslint-disable-line no-throw-literal
throw "simple string error";
};
test("renders string error", () => {

View File

@ -43,7 +43,7 @@ class MoonfireErrorBoundary extends React.Component<Props, State> {
const { children } = this.props;
if (this.state.error !== null) {
var error;
let error;
if (this.state.error.stack !== undefined) {
error = <pre>{this.state.error.stack}</pre>;
} else if (this.state.error instanceof Error) {

View File

@ -2,15 +2,15 @@
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Card from "@mui/material/Card";
import Checkbox from "@mui/material/Checkbox";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import React from "react";
import { useTheme } from "@mui/material/styles";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormGroup from "@mui/material/FormGroup";
import Paper from "@mui/material/Paper";
import { useTheme } from "@mui/material/styles";
interface Props {
split90k?: number;
@ -36,70 +36,70 @@ export const DEFAULT_DURATION = DURATIONS[0][1];
const DisplaySelector = (props: Props) => {
const theme = useTheme();
return (
<Card
sx={{
padding: theme.spacing(1),
display: "flex",
flexDirection: "column",
}}
>
<FormControl fullWidth variant="outlined">
<InputLabel id="split90k-label" shrink>
Max video duration
</InputLabel>
<Select
labelId="split90k-label"
label="Max video duration"
id="split90k"
size="small"
value={props.split90k}
onChange={(e) =>
props.setSplit90k(
typeof e.target.value === "string"
? parseInt(e.target.value)
: e.target.value
)
}
displayEmpty
>
{DURATIONS.map(([l, d]) => (
<MenuItem key={l} value={d}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
title="Trim each segment of video so that it is fully
<Paper style={{ padding: theme.spacing(1) }}>
<FormGroup>
<FormControl fullWidth variant="outlined">
<InputLabel id="split90k-label" shrink>
Max video duration
</InputLabel>
<Select
labelId="split90k-label"
label="Max video duration"
id="split90k"
size="small"
value={props.split90k}
onChange={(e) =>
props.setSplit90k(
typeof e.target.value === "string"
? parseInt(e.target.value)
: e.target.value
)
}
displayEmpty
>
{DURATIONS.map(([l, d]) => (
<MenuItem key={l} value={d}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
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."
control={
<Checkbox
checked={props.trimStartAndEnd}
size="small"
onChange={(event) => props.setTrimStartAndEnd(event.target.checked)}
name="trim-start-and-end"
color="secondary"
/>
}
label="Trim start and end"
/>
<FormControlLabel
title="Include a text track in each .mp4 with the
control={
<Checkbox
checked={props.trimStartAndEnd}
size="small"
onChange={(event) =>
props.setTrimStartAndEnd(event.target.checked)
}
name="trim-start-and-end"
color="secondary"
/>
}
label="Trim start and end"
/>
<FormControlLabel
title="Include a text track in each .mp4 with the
timestamp at which the video was recorded."
control={
<Checkbox
checked={props.timestampTrack}
size="small"
onChange={(event) => props.setTimestampTrack(event.target.checked)}
name="timestamp-track"
color="secondary"
/>
}
label="Timestamp track"
/>
</Card>
control={
<Checkbox
checked={props.timestampTrack}
size="small"
onChange={(event) =>
props.setTimestampTrack(event.target.checked)
}
name="timestamp-track"
color="secondary"
/>
}
label="Timestamp track"
/>
</FormGroup>
</Paper>
);
};

View File

@ -3,11 +3,11 @@
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import { Camera, Stream, StreamType } from "../types";
import Checkbox from "@mui/material/Checkbox";
import { useTheme } from "@mui/material/styles";
import { ToplevelResponse } from "../api";
import Paper from "@mui/material/Paper";
import { useTheme } from "@mui/material/styles";
interface Props {
toplevel: ToplevelResponse;
@ -91,11 +91,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
);
});
return (
<Card
sx={{
padding: theme.spacing(1),
}}
>
<Paper sx={{ padding: theme.spacing(1) }}>
<Box
component="table"
sx={{
@ -125,7 +121,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
</thead>
<tbody>{cameraRows}</tbody>
</Box>
</Card>
</Paper>
);
};

View File

@ -59,9 +59,6 @@ import React, { useEffect } from "react";
import { zonedTimeToUtc } from "date-fns-tz";
import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns";
import startOfDay from "date-fns/startOfDay";
import Card from "@mui/material/Card";
import { useTheme } from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormLabel from "@mui/material/FormLabel";
import Radio from "@mui/material/Radio";
@ -69,6 +66,8 @@ import RadioGroup from "@mui/material/RadioGroup";
import { TimePicker, TimePickerProps } from "@mui/x-date-pickers/TimePicker";
import Collapse from "@mui/material/Collapse";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import { useTheme } from "@mui/material/styles";
interface Props {
selectedStreams: Set<Stream>;
@ -77,20 +76,24 @@ interface Props {
}
const MyTimePicker = (
props: Pick<TimePickerProps<Date, Date>, "value" | "onChange" | "disabled">
props: Pick<TimePickerProps<Date>, "value" | "onChange" | "disabled">
) => (
<TimePicker
label="Time"
views={["hours", "minutes", "seconds"]}
renderInput={(params) => <TextField fullWidth size="small" {...params} />}
inputFormat="HH:mm:ss"
mask="__:__:__"
slotProps={{
textField: {
fullWidth: true,
size: "small",
variant: "outlined",
},
}}
ampm={false}
{...props}
/>
);
const SmallStaticDatePicker = (props: StaticDatePickerProps<Date, Date>) => {
const SmallStaticDatePicker = (props: StaticDatePickerProps<Date>) => {
// The spacing defined at https://material.io/components/date-pickers#specs
// seems plenty big enough (on desktop). Not sure why material-ui wants
// to make it bigger but that doesn't work well with our layout.
@ -101,26 +104,30 @@ const SmallStaticDatePicker = (props: StaticDatePickerProps<Date, Date>) => {
<Box
sx={{
"@media (pointer: fine)": {
"& .MuiPickerStaticWrapper-content": {
"& .MuiPickersLayout-root": {
minWidth: "auto", // defaults to 320px
},
"& .MuiCalendarOrClockPicker-root > div, & .MuiCalendarPicker-root": {
width: 256, // defaults to 320px
margin: 0,
},
"& .MuiPickersLayout-root, & .MuiPickersLayout-contentWrapper, & .MuiDateCalendar-root":
{
width: 256, // defaults to 320px
margin: 0,
},
"& .MuiPickersArrowSwitcher-spacer": {
// By default, this spacer is so big that there's not enough space
// in the row for October. Shrink it.
width: 12,
},
"& .MuiDayPicker-weekDayLabel": {
"& .MuiDayCalendar-weekDayLabel": {
width: DATE_SIZE,
margin: 0,
},
"& .PrivatePickersSlideTransition-root": {
"& .MuiDayCalendar-slideTransition": {
minHeight: DATE_SIZE * 6,
},
"& .MuiDayPicker-weekContainer": {
"& .MuiDateCalendar-root": {
height: "auto",
},
"& .MuiDayCalendar-weekContainer": {
margin: 0,
},
"& .MuiPickersDay-dayWithMargin": {
@ -133,7 +140,7 @@ const SmallStaticDatePicker = (props: StaticDatePickerProps<Date, Date>) => {
},
}}
>
<StaticDatePicker {...props} />
<StaticDatePicker {...props} sx={{ background: "transparent" }} />
</Box>
);
};
@ -291,7 +298,7 @@ function daysStateReducer(old: DaysState, op: DaysOp): DaysState {
}
}
break;
case "set-end-day":
case "set-end-day": {
const millis = toMillis(op.newEndDate);
if (
state.rangeMillis === null ||
@ -303,6 +310,7 @@ function daysStateReducer(old: DaysState, op: DaysOp): DaysState {
state.rangeMillis[1] = millis;
}
break;
}
case "set-end-type":
state.endType = op.newEndType;
if (state.endType === "same-day" && state.rangeMillis !== null) {
@ -363,8 +371,8 @@ const TimerangeSelector = ({
endDate = new Date(days.rangeMillis[1]);
}
return (
<Card sx={{ padding: theme.spacing(1) }}>
<div>
<Paper sx={{ padding: theme.spacing(1) }}>
<Box>
<FormLabel component="legend">From</FormLabel>
<SmallStaticDatePicker
displayStaticWrapperAs="desktop"
@ -380,7 +388,6 @@ const TimerangeSelector = ({
onChange={(d: Date | null) => {
updateDays({ op: "set-start-day", newStartDate: d });
}}
renderInput={(params) => <TextField {...params} variant="outlined" />}
/>
<MyTimePicker
value={startTime}
@ -391,9 +398,11 @@ const TimerangeSelector = ({
}}
disabled={days.allowed === null}
/>
</div>
<div>
<FormLabel component="legend">To</FormLabel>
</Box>
<Box>
<FormLabel sx={{ mt: 1 }} component="legend">
To
</FormLabel>
<RadioGroup
row
value={days.endType}
@ -429,9 +438,6 @@ const TimerangeSelector = ({
onChange={(d: Date | null) => {
updateDays({ op: "set-end-day", newEndDate: d! });
}}
renderInput={(params) => (
<TextField {...params} variant="outlined" />
)}
/>
</Collapse>
<MyTimePicker
@ -443,8 +449,8 @@ const TimerangeSelector = ({
}}
disabled={days.allowed === null}
/>
</div>
</Card>
</Box>
</Paper>
);
};

View File

@ -5,12 +5,13 @@
import { screen } from "@testing-library/react";
import { utcToZonedTime } from "date-fns-tz";
import format from "date-fns/format";
import { rest } from "msw";
import { DefaultBodyType, delay, http, HttpResponse, PathParams } from "msw";
import { setupServer } from "msw/node";
import { Recording, VideoSampleEntry } from "../api";
import { renderWithCtx } from "../testutil";
import { Camera, Stream } from "../types";
import VideoList from "./VideoList";
import VideoList, { combine } from "./VideoList";
import { beforeAll, afterAll, afterEach, expect, test } from "vitest";
const TEST_CAMERA: Camera = {
uuid: "c7278ba0-a001-420c-911e-fff4e33f6916",
@ -44,9 +45,30 @@ const TEST_RANGE2: [number, number] = [
];
const TEST_RECORDINGS1: Recording[] = [
{
startId: 44,
openId: 1,
runStartId: 44,
startTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
endTime90k: 145750558950000, // 2021-04-26T08:24:15:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
{
startId: 43,
openId: 1,
runStartId: 40,
startTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
endTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
{
startId: 42,
openId: 1,
runStartId: 40,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
videoSampleEntryId: 4,
@ -59,6 +81,7 @@ const TEST_RECORDINGS2: Recording[] = [
{
startId: 42,
openId: 1,
runStartId: 40,
startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00
endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00
videoSampleEntryId: 4,
@ -84,37 +107,90 @@ function TestFormat(time90k: number) {
}
const server = setupServer(
rest.get("/api/cameras/:camera/:streamType/recordings", (req, res, ctx) => {
const p = req.url.searchParams;
const range90k = [
parseInt(p.get("startTime90k")!, 10),
parseInt(p.get("endTime90k")!, 10),
];
if (range90k[0] === 42) {
return res(ctx.status(503), ctx.text("server error"));
}
if (range90k[0] === TEST_RANGE1[0]) {
return res(
ctx.json({
http.get<PathParams, DefaultBodyType, any>(
"/api/cameras/:camera/:streamType/recordings",
async ({ request }) => {
const url = new URL(request.url);
const p = url.searchParams;
const range90k = [
parseInt(p.get("startTime90k")!, 10),
parseInt(p.get("endTime90k")!, 10),
];
if (range90k[0] === 42) {
return HttpResponse.text("server error", { status: 503 });
}
if (range90k[0] === TEST_RANGE1[0]) {
return HttpResponse.json({
recordings: TEST_RECORDINGS1,
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
})
);
} else {
return res(
ctx.delay(2000), // 2 second delay
ctx.json({
});
} else {
await delay(2000); // 2 second delay
return HttpResponse.json({
recordings: TEST_RECORDINGS2,
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
})
);
});
}
}
})
)
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("combine", () => {
const actual = combine(undefined, {
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
recordings: TEST_RECORDINGS1,
});
const expected = [
// 44 shouldn't be combined; it's not from the same run as the others.
{
startId: 44,
endId: 44,
openId: 1,
runStartId: 44,
startTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
endTime90k: 145750558950000, // 2021-04-26T08:24:15:00000-07:00
videoSamples: 1860,
sampleFileBytes: 248000,
aspectWidth: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectWidth,
aspectHeight: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectHeight,
width: TEST_VIDEO_SAMPLE_ENTRIES[4].width,
height: TEST_VIDEO_SAMPLE_ENTRIES[4].height,
firstUncommitted: undefined,
growing: undefined,
},
// 42 and 43 are combinable.
{
startId: 42,
endId: 43,
openId: 1,
runStartId: 40,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
videoSamples: 3720,
sampleFileBytes: 496000,
aspectWidth: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectWidth,
aspectHeight: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectHeight,
width: TEST_VIDEO_SAMPLE_ENTRIES[4].width,
height: TEST_VIDEO_SAMPLE_ENTRIES[4].height,
firstUncommitted: undefined,
growing: undefined,
},
];
// XXX: unsure why this doesn't work:
//
// expect(actual).toContainEqual(expected)
//
// ...but this does:
expect(actual).toHaveLength(expected.length);
for (let i = 0; i < expected.length; i++) {
expect(actual[i]).toEqual(expected[i]);
}
});
test("load", async () => {
renderWithCtx(
<table>
@ -162,10 +238,15 @@ test("slow replace", async () => {
expect(screen.getByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
// A loading indicator should show up after a second.
expect(await screen.findByRole("progressbar")).toBeInTheDocument();
// The default timeout is 1 second; extend it to pass reliably.
expect(
await screen.findByRole("progressbar", {}, { timeout: 2000 })
).toBeInTheDocument();
// Then the second query result should show up.
expect(await screen.findByText(/27 Apr 2021 06:17:43/)).toBeInTheDocument();
expect(
await screen.findByText(/27 Apr 2021 06:17:43/, {}, { timeout: 2000 })
).toBeInTheDocument();
});
test("error", async () => {

View File

@ -11,18 +11,107 @@ import TableCell from "@mui/material/TableCell";
import TableRow, { TableRowProps } from "@mui/material/TableRow";
import Skeleton from "@mui/material/Skeleton";
import Alert from "@mui/material/Alert";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
interface Props {
stream: Stream;
range90k: [number, number] | null;
split90k?: number;
trimStartAndEnd: boolean;
setActiveRecording: (
recording: [Stream, api.Recording, api.VideoSampleEntry] | null
) => void;
setActiveRecording: (recording: [Stream, CombinedRecording] | null) => void;
formatTime: (time90k: number) => string;
}
/**
* Matches `api.Recording`, except that two entries with differing
* `videoSampleEntryId` but the same resolution may be combined.
*/
export interface CombinedRecording {
startId: number;
endId?: number;
runStartId: number;
firstUncommitted?: number;
growing?: boolean;
openId: number;
startTime90k: number;
endTime90k: number;
videoSamples: number;
sampleFileBytes: number;
width: number;
height: number;
aspectWidth: number;
aspectHeight: number;
endReason?: string;
}
/**
* Combines recordings, which are assumed to already be sorted in descending
* chronological order.
*
* This is exported only for testing.
*/
export function combine(
split90k: number | undefined,
response: api.RecordingsResponse
): CombinedRecording[] {
let out = [];
let cur = null;
for (const r of response.recordings) {
const vse = response.videoSampleEntries[r.videoSampleEntryId];
// Combine `r` into `cur` if `r` precedes `cur`, shouldn't be split, and
// has similar resolution. It doesn't have to have exactly the same
// video sample entry; minor changes to encoding can be seamlessly
// combined into one `.mp4` file.
if (
cur !== null &&
r.openId === cur.openId &&
r.runStartId === cur.runStartId &&
(r.endId ?? r.startId) + 1 === cur.startId &&
cur.width === vse.width &&
cur.height === vse.height &&
cur.aspectWidth === vse.aspectWidth &&
cur.aspectHeight === vse.aspectHeight &&
(split90k === undefined || cur.endTime90k - r.startTime90k <= split90k)
) {
cur.startId = r.startId;
cur.firstUncommitted == r.firstUncommitted ?? cur.firstUncommitted;
cur.startTime90k = r.startTime90k;
cur.videoSamples += r.videoSamples;
cur.sampleFileBytes += r.sampleFileBytes;
continue;
}
// Otherwise, start a new `cur`, flushing any existing one.
if (cur !== null) {
out.push(cur);
}
cur = {
startId: r.startId,
endId: r.endId ?? r.startId,
runStartId: r.runStartId,
firstUncommitted: r.firstUncommitted,
growing: r.growing,
openId: r.openId,
startTime90k: r.startTime90k,
endTime90k: r.endTime90k,
videoSamples: r.videoSamples,
sampleFileBytes: r.sampleFileBytes,
width: vse.width,
height: vse.height,
aspectWidth: vse.aspectWidth,
aspectHeight: vse.aspectHeight,
endReason: r.endReason,
};
}
if (cur !== null) {
out.push(cur);
}
return out;
}
const frameRateFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 0,
});
@ -37,12 +126,14 @@ interface State {
* During loading, this can differ from the requested range.
*/
range90k: [number, number];
response: { status: "skeleton" } | api.FetchResult<api.RecordingsResponse>;
split90k?: number;
response: { status: "skeleton" } | api.FetchResult<CombinedRecording[]>;
}
interface RowProps extends TableRowProps {
start: React.ReactNode;
end: React.ReactNode;
endReason?: string;
resolution: React.ReactNode;
fps: React.ReactNode;
storage: React.ReactNode;
@ -52,6 +143,7 @@ interface RowProps extends TableRowProps {
const Row = ({
start,
end,
endReason,
resolution,
fps,
storage,
@ -60,7 +152,15 @@ const Row = ({
}: RowProps) => (
<TableRow {...rest}>
<TableCell align="right">{start}</TableCell>
<TableCell align="right">{end}</TableCell>
<TableCell align="right">
{endReason !== undefined ? (
<Tooltip title={endReason}>
<Typography>{end}</Typography>
</Tooltip>
) : (
end
)}
</TableCell>
<TableCell align="right" className="opt">
{resolution}
</TableCell>
@ -111,12 +211,21 @@ const VideoList = ({
split90k,
};
let response = await api.recordings(req, { signal });
if (response.status === "success") {
// Sort recordings in descending order by start time.
response.response.recordings.sort((a, b) => b.startId - a.startId);
}
clearTimeout(timerId);
setState({ range90k, response });
if (response.status === "success") {
// Sort recordings in descending order.
response.response.recordings.sort((a, b) => b.startId - a.startId);
setState({
range90k,
split90k,
response: {
status: "success",
response: combine(split90k, response.response),
},
});
} else {
setState({ range90k, split90k, response });
}
};
if (range90k !== null) {
const timerId = setTimeout(
@ -157,8 +266,7 @@ const VideoList = ({
);
} else if (state.response.status === "success") {
const resp = state.response.response;
body = resp.recordings.map((r: api.Recording) => {
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
body = resp.map((r: CombinedRecording) => {
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
const rate = (r.sampleFileBytes / durationSec) * 0.000008;
const start = trimStartAndEnd
@ -171,10 +279,11 @@ const VideoList = ({
<Row
key={r.startId}
className="recording"
onClick={() => setActiveRecording([stream, r, vse])}
onClick={() => setActiveRecording([stream, r])}
start={formatTime(start)}
end={formatTime(end)}
resolution={`${vse.width}x${vse.height}`}
endReason={r.endReason}
resolution={`${r.width}x${r.height}`}
fps={frameRateFmt.format(r.videoSamples / durationSec)}
storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
bitrate={`${sizeFmt.format(rate)} Mbps`}

View File

@ -16,7 +16,7 @@ import { Stream } from "../types";
import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector";
import StreamMultiSelector from "./StreamMultiSelector";
import TimerangeSelector from "./TimerangeSelector";
import VideoList from "./VideoList";
import VideoList, { CombinedRecording } from "./VideoList";
import { useLayoutEffect } from "react";
import { fillAspect } from "../aspect";
import useResizeObserver from "@react-hook/resize-observer";
@ -208,7 +208,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
);
const [activeRecording, setActiveRecording] = useState<
[Stream, api.Recording, api.VideoSampleEntry] | null
[Stream, CombinedRecording] | null
>(null);
const formatTime = useMemo(() => {
return (time90k: number) => {
@ -240,6 +240,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
<TableContainer
component={Paper}
sx={{
mx: 1,
flexGrow: 1,
width: "max-content",
height: "max-content",
@ -272,6 +273,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
aria-label="selectors"
onClick={toggleShowSelectors}
color="inherit"
sx={showSelectors ? { border: `1px solid #eee` } : {}}
size="small"
>
<FilterList />
@ -287,12 +289,12 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
>
<Box
sx={{
display: showSelectors ? "block" : "none",
width: "max-content",
"& .MuiCard-root": {
marginRight: theme.spacing(2),
marginBottom: theme.spacing(2),
},
display: showSelectors ? "flex" : "none",
maxWidth: { xs: "100%", sm: "300px", md: "300px" },
gap: 1,
mb: 2,
flexDirection: "column",
}}
>
<StreamMultiSelector
@ -341,8 +343,8 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
trimStartAndEnd ? range90k! : undefined
)}
aspect={[
activeRecording[2].aspectWidth,
activeRecording[2].aspectHeight,
activeRecording[1].aspectWidth,
activeRecording[1].aspectHeight,
]}
/>
</Modal>

View File

@ -2,7 +2,7 @@
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import React, { SyntheticEvent } from "react";
import React from "react";
import { Camera } from "../types";
import { Part, parsePart } from "./parser";
import * as api from "../api";
@ -12,7 +12,20 @@ import Alert from "@mui/material/Alert";
import useResizeObserver from "@react-hook/resize-observer";
import { fillAspect } from "../aspect";
/// The media source API to use:
/// * Essentially everything but iPhone supports `MediaSource`.
/// (All major desktop browsers; Android browsers; and Safari on iPad are
/// fine.)
/// * Safari/macOS and Safari/iPhone on iOS 17+ support `ManagedMediaSource`.
/// * Safari/iPhone with older iOS does not support anything close to
/// `MediaSource`.
export const MediaSourceApi: typeof MediaSource | undefined =
(self as any).ManagedMediaSource ?? self.MediaSource;
interface LiveCameraProps {
/// Caller should provide a failure path when `MediaSourceApi` is undefined
/// and pass it back here otherwise.
mediaSourceApi: typeof MediaSource;
camera: Camera | null;
chooser: JSX.Element;
}
@ -60,18 +73,45 @@ type PlaybackState =
*/
class LiveCameraDriver {
constructor(
mediaSourceApi: typeof MediaSource,
camera: Camera,
setPlaybackState: (state: PlaybackState) => void,
setAspect: (aspect: [number, number]) => void,
videoRef: React.RefObject<HTMLVideoElement>
video: HTMLVideoElement
) {
this.mediaSourceApi = mediaSourceApi;
this.src = new mediaSourceApi();
this.camera = camera;
this.setPlaybackState = setPlaybackState;
this.setAspect = setAspect;
this.videoRef = videoRef;
this.video = video;
video.addEventListener("pause", this.videoPause);
video.addEventListener("play", this.videoPlay);
video.addEventListener("playing", this.videoPlaying);
video.addEventListener("timeupdate", this.videoTimeUpdate);
video.addEventListener("waiting", this.videoWaiting);
this.src.addEventListener("sourceopen", this.onMediaSourceOpen);
// This appears necessary for the `ManagedMediaSource` API to function
// on Safari/iOS.
video["disableRemotePlayback"] = true;
video.src = this.objectUrl = URL.createObjectURL(this.src);
video.load();
}
unmount = () => {
this.stopStream("unmount");
const v = this.video;
v.removeEventListener("pause", this.videoPause);
v.removeEventListener("play", this.videoPlay);
v.removeEventListener("playing", this.videoPlaying);
v.removeEventListener("timeupdate", this.videoTimeUpdate);
v.removeEventListener("waiting", this.videoWaiting);
v.src = "";
URL.revokeObjectURL(this.objectUrl);
v.load();
};
onMediaSourceOpen = () => {
this.startStream("sourceopen");
};
@ -150,7 +190,7 @@ class LiveCameraDriver {
return;
}
const part = result.part;
if (!MediaSource.isTypeSupported(part.mimeType)) {
if (!this.mediaSourceApi.isTypeSupported(part.mimeType)) {
this.error(`unsupported mime type ${part.mimeType}`);
return;
}
@ -252,12 +292,11 @@ class LiveCameraDriver {
if (
this.buf.state !== "open" ||
this.buf.busy ||
this.buf.srcBuf.buffered.length === 0 ||
this.videoRef.current === null
this.buf.srcBuf.buffered.length === 0
) {
return;
}
const curTs = this.videoRef.current.currentTime;
const curTs = this.video.currentTime;
// TODO: call out key frames in the part headers. The "- 5" here is a guess
// to avoid removing anything from the current GOP.
@ -273,13 +312,23 @@ class LiveCameraDriver {
this.error(`SourceBuffer ${e.type}`);
};
videoPlaying = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
videoPause = () => {
this.stopStream("pause");
};
videoPlay = () => {
this.startStream("play");
};
videoPlaying = () => {
if (this.buf.state !== "error") {
this.setPlaybackState({ state: "normal" });
}
};
videoWaiting = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
videoTimeUpdate = () => {};
videoWaiting = () => {
if (this.buf.state !== "error") {
this.setPlaybackState({ state: "waiting" });
}
@ -302,15 +351,16 @@ class LiveCameraDriver {
camera: Camera;
setPlaybackState: (state: PlaybackState) => void;
setAspect: (aspect: [number, number]) => void;
videoRef: React.RefObject<HTMLVideoElement>;
video: HTMLVideoElement;
src = new MediaSource();
mediaSourceApi: typeof MediaSource;
src: MediaSource;
buf: BufferState = { state: "closed" };
queue: Part[] = [];
queuedBytes: number = 0;
/// The object URL for the HTML video element, not the WebSocket URL.
url = URL.createObjectURL(this.src);
objectUrl: string;
ws?: WebSocket;
}
@ -321,9 +371,8 @@ class LiveCameraDriver {
* Note there's a significant setup cost to creating a LiveCamera, so the parent
* should use React's <tt>key</tt> attribute to avoid unnecessarily mounting
* and unmounting a camera.
*
*/
const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
const LiveCamera = ({ mediaSourceApi, camera, chooser }: LiveCameraProps) => {
const [aspect, setAspect] = React.useState<[number, number]>([16, 9]);
const videoRef = React.useRef<HTMLVideoElement>(null);
const boxRef = React.useRef<HTMLElement>(null);
@ -339,27 +388,23 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
});
// Load the camera driver.
const [driver, setDriver] = React.useState<LiveCameraDriver | null>(null);
React.useEffect(() => {
setPlaybackState({ state: "normal" });
if (camera === null) {
setDriver(null);
const video = videoRef.current;
if (camera === null || video === null) {
return;
}
const d = new LiveCameraDriver(
mediaSourceApi,
camera,
setPlaybackState,
setAspect,
videoRef
video
);
setDriver(d);
return () => {
// Explictly stop the stream on unmount. There don't seem to be any DOM
// event handlers that run in this case. (In particular, the MediaSource's
// sourceclose doesn't run.)
d.stopStream("unmount or camera change");
d.unmount();
};
}, [camera]);
}, [mediaSourceApi, camera]);
// Display circular progress after 100 ms of waiting.
const [showProgress, setShowProgress] = React.useState(false);
@ -372,22 +417,6 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
return () => clearTimeout(timerId);
}, [playbackState]);
const videoElement =
driver === null ? (
<video />
) : (
<video
ref={videoRef}
muted
autoPlay
src={driver.url}
onPause={() => driver.stopStream("pause")}
onPlay={() => driver.startStream("play")}
onPlaying={driver.videoPlaying}
onTimeUpdate={driver.tryTrimBuffer}
onWaiting={driver.videoWaiting}
/>
);
return (
<Box
ref={boxRef}
@ -446,7 +475,7 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
<Alert severity="error">{playbackState.message}</Alert>
</div>
)}
{videoElement}
<video ref={videoRef} muted autoPlay />
</Box>
);
};

View File

@ -5,22 +5,26 @@
import Box from "@mui/material/Box";
import Select, { SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import React, { useReducer } from "react";
import React, { useCallback, useEffect, useReducer } from "react";
import { Camera } from "../types";
import { useTheme } from "@mui/material/styles";
import { useSearchParams } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import Fullscreen from "@mui/icons-material/Fullscreen";
export interface Layout {
className: string;
cameras: number;
name: string;
}
// These class names must match useStyles rules (below).
const LAYOUTS: Layout[] = [
{ className: "solo", cameras: 1 },
{ className: "main-plus-five", cameras: 6 },
{ className: "two-by-two", cameras: 4 },
{ className: "three-by-three", cameras: 9 },
{ className: "solo", cameras: 1, name: "1" },
{ className: "dual", cameras: 2, name: "2" },
{ className: "main-plus-five", cameras: 6, name: "Main + 5" },
{ className: "two-by-two", cameras: 4, name: "2x2" },
{ className: "three-by-three", cameras: 9, name: "3x3" },
];
const MAX_CAMERAS = 9;
@ -63,7 +67,7 @@ export const MultiviewChooser = (props: MultiviewChooserProps) => {
>
{LAYOUTS.map((e, i) => (
<MenuItem key={e.className} value={i}>
{e.className}
{e.name}
</MenuItem>
))}
</Select>
@ -106,18 +110,45 @@ function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras {
*/
const Multiview = (props: MultiviewProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const theme = useTheme();
const [selected, updateSelected] = useReducer(
selectedReducer,
searchParams.has("cams")
? JSON.parse(searchParams.get("cams") || "")
: localStorage.getItem("camsSelected") !== null
? JSON.parse(localStorage.getItem("camsSelected") || "")
: Array(MAX_CAMERAS).fill(null)
);
/**
* Save previously selected cameras to local storage.
*/
useEffect(() => {
if (searchParams.has("cams"))
localStorage.setItem("camsSelected", searchParams.get("cams") || "");
}, [searchParams]);
const outerRef = React.useRef<HTMLDivElement>(null);
const layout = LAYOUTS[props.layoutIndex];
/**
* Toggle full screen.
*/
const handleFullScreen = useCallback(() => {
if (outerRef.current) {
const elem = outerRef.current;
if (document.fullscreenElement) {
if (document.exitFullscreen) {
document.exitFullscreen();
}
} else {
if (elem.requestFullscreen) {
elem.requestFullscreen();
}
}
}
}, [outerRef]);
const monoviews = selected.slice(0, layout.cameras).map((e, i) => {
// When a camera is selected, use the camera's index as the key.
// This allows swapping cameras' positions without tearing down their
@ -153,7 +184,6 @@ const Multiview = (props: MultiviewProps) => {
sx={{
flex: "1 0 0",
color: "white",
marginTop: theme.spacing(2),
overflow: "hidden",
// TODO: this mid-level div can probably be removed.
@ -165,6 +195,27 @@ const Multiview = (props: MultiviewProps) => {
},
}}
>
<Tooltip title="Toggle full screen">
<IconButton
size="small"
sx={{
position: "fixed",
background: "rgba(50,50,50,0.4) !important",
transition: "0.2s",
opacity: "0.4",
bottom: 10,
right: 10,
zIndex: 9,
color: "#fff",
":hover": {
opacity: "1",
},
}}
onClick={handleFullScreen}
>
<Fullscreen />
</IconButton>
</Tooltip>
<div className="mid">
<Box
className={layout.className}
@ -184,6 +235,18 @@ const Multiview = (props: MultiviewProps) => {
gridTemplateColumns: "100%",
gridTemplateRows: "100%",
},
"&.dual": {
gridTemplateColumns: {
xs: "100%",
sm: "100%",
md: "repeat(2, calc(100% / 2))",
},
gridTemplateRows: {
xs: "50%",
sm: "50%",
md: "repeat(1, calc(100% / 1))",
},
},
"&.two-by-two": {
gridTemplateColumns: "repeat(2, calc(100% / 2))",
gridTemplateRows: "repeat(2, calc(100% / 2))",
@ -229,8 +292,11 @@ const Monoview = (props: MonoviewProps) => {
displayEmpty
size="small"
sx={{
transform: "scale(0.8)",
// Restyle to fit over the video (or black).
backgroundColor: "rgba(255, 255, 255, 0.5)",
backgroundColor: "rgba(50, 50, 50, 0.6)",
boxShadow: "0 0 10px rgba(0, 0, 0, 0.4)",
color: "#fff",
"& svg": {
color: "inherit",
},

View File

@ -5,11 +5,11 @@
import Container from "@mui/material/Container";
import ErrorIcon from "@mui/icons-material/Error";
import { Camera } from "../types";
import LiveCamera from "./LiveCamera";
import LiveCamera, { MediaSourceApi } from "./LiveCamera";
import Multiview, { MultiviewChooser } from "./Multiview";
import { FrameProps } from "../App";
import { useSearchParams } from "react-router-dom";
import { useState } from "react";
import { useEffect, useState } from "react";
export interface LiveProps {
cameras: Camera[];
@ -20,10 +20,24 @@ const Live = ({ cameras, Frame }: LiveProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(
Number.parseInt(searchParams.get("layout") || "0", 10)
Number.parseInt(
searchParams.get("layout") ||
localStorage.getItem("multiviewLayoutIndex") ||
"0",
10
)
);
if ("MediaSource" in window === false) {
useEffect(() => {
if (searchParams.has("layout"))
localStorage.setItem(
"multiviewLayoutIndex",
searchParams.get("layout") || "0"
);
}, [searchParams]);
const mediaSourceApi = MediaSourceApi;
if (mediaSourceApi === undefined) {
return (
<Frame>
<Container>
@ -59,7 +73,11 @@ const Live = ({ cameras, Frame }: LiveProps) => {
layoutIndex={multiviewLayoutIndex}
cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} />
<LiveCamera
mediaSourceApi={mediaSourceApi}
camera={camera}
chooser={chooser}
/>
)}
/>
</Frame>

View File

@ -29,7 +29,7 @@ export function parsePart(raw: Uint8Array): ParseResult {
// Parse into headers and body.
const headers = new Headers();
let pos = 0;
while (true) {
for (;;) {
const cr = raw.indexOf(CR, pos);
if (cr === -1 || raw.length === cr + 1 || raw[cr + 1] !== NL) {
return {

View File

@ -2,65 +2,67 @@
// 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 {
screen,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { delay, http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import Login from "./Login";
import { renderWithCtx } from "./testutil";
import {
beforeAll,
afterEach,
afterAll,
test,
vi,
expect,
beforeEach,
} from "vitest";
// 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>;
http.post<any, Record<string, string>>("/api/login", async ({ request }) => {
const body = await request.json();
const { username, password } = body!;
console.log(
"/api/login post username=" + username + " password=" + password
);
if (username === "slamb" && password === "hunter2") {
return res(ctx.status(204));
return new HttpResponse(null, { status: 204 });
} else if (username === "delay") {
return res(ctx.delay("infinite"));
await delay("infinite");
return new HttpResponse(null);
} else if (username === "server-error") {
return res(ctx.status(503), ctx.text("server error"));
return HttpResponse.text("server error", { status: 503 });
} else if (username === "network-error") {
return res.networkError("network error");
return HttpResponse.error();
} else {
return res(ctx.status(401), ctx.text("bad credentials"));
return HttpResponse.text("bad credentials", { status: 401 });
}
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
beforeEach(() => {
// Using fake timers allows tests to jump forward to when a snackbar goes away, without incurring
// extra real delay. msw only appears to work when `shouldAdvanceTime` is set though.
vi.useFakeTimers({
shouldAdvanceTime: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
server.resetHandlers();
});
afterAll(() => server.close());
// TODO: fix this. It's meant to allow the snack bar timers to run quickly
// in tests, but fake timers seem to have problems:
//
// * react-scripts v4, @testing-library/react v11: worked
// * react-scripts v5, @testing-library/react v11:
// saw "was not wrapped in act" warnings, test failed
// (Seems like waitFor's internal advance calls aren't wrapped in act)
// * react-scripts v5, @testing-library/react v12:
// msw requests never complete
// https://github.com/mswjs/msw/issues/448#issuecomment-723438099 ?
// https://github.com/facebook/jest/issues/11103 ?
// https://github.com/facebook/jest/issues/13018 ?
//
// Argh!
// beforeEach(() => jest.useFakeTimers({
// legacyFakeTimers: true,
// }));
// afterEach(() => {
// act(() => {
// jest.runOnlyPendingTimers();
// jest.useRealTimers();
// });
// });
test("success", async () => {
const user = userEvent.setup();
const handleClose = jest.fn().mockName("handleClose");
const onSuccess = jest.fn().mockName("handleOpen");
const handleClose = vi.fn().mockName("handleClose");
const onSuccess = vi.fn().mockName("handleOpen");
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
@ -69,19 +71,15 @@ test("success", async () => {
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
// TODO: fix and re-enable this test.
// Currently it makes "CI=true npm run test" hang.
// I think the problem is that npmjs doesn't really support aborting requests,
// so the delay("infinite") request just sticks around, even though the fetch
// has been aborted. Maybe https://github.com/mswjs/msw/pull/585 will fix it.
xtest("close while pending", async () => {
const handleClose = jest.fn().mockName("handleClose");
const onSuccess = jest.fn().mockName("handleOpen");
test("close while pending", async () => {
const user = userEvent.setup();
const handleClose = vi.fn().mockName("handleClose");
const onSuccess = vi.fn().mockName("handleOpen");
const { rerender } = renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
userEvent.type(screen.getByLabelText(/Username/), "delay");
userEvent.type(screen.getByLabelText(/Password/), "hunter2{enter}");
await user.type(screen.getByLabelText(/Username/), "delay");
await user.type(screen.getByLabelText(/Password/), "hunter2{enter}");
expect(screen.getByRole("button", { name: /Log in/ })).toBeInTheDocument();
rerender(
<Login open={false} onSuccess={onSuccess} handleClose={handleClose} />
@ -93,50 +91,46 @@ xtest("close while pending", async () => {
);
});
// TODO: fix and re-enable this test.
// It depends on the timers; see TODO above.
xtest("bad credentials", async () => {
const handleClose = jest.fn().mockName("handleClose");
const onSuccess = jest.fn().mockName("handleOpen");
test("bad credentials", async () => {
const user = userEvent.setup();
const handleClose = vi.fn().mockName("handleClose");
const onSuccess = vi.fn().mockName("handleOpen");
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
await userEvent.type(screen.getByLabelText(/Username/), "slamb");
await userEvent.type(screen.getByLabelText(/Password/), "wrong{enter}");
await user.type(screen.getByLabelText(/Username/), "slamb");
await user.type(screen.getByLabelText(/Password/), "wrong{enter}");
await screen.findByText(/bad credentials/);
expect(onSuccess).toHaveBeenCalledTimes(0);
});
// TODO: fix and re-enable this test.
// It depends on the timers; see TODO above.
xtest("server error", async () => {
const handleClose = jest.fn().mockName("handleClose");
const onSuccess = jest.fn().mockName("handleOpen");
test("server error", async () => {
const user = userEvent.setup();
const handleClose = vi.fn().mockName("handleClose");
const onSuccess = vi.fn().mockName("handleOpen");
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
await userEvent.type(screen.getByLabelText(/Username/), "server-error");
await userEvent.type(screen.getByLabelText(/Password/), "asdf{enter}");
await user.type(screen.getByLabelText(/Username/), "server-error");
await user.type(screen.getByLabelText(/Password/), "asdf{enter}");
await screen.findByText(/server error/);
await waitFor(() =>
expect(screen.queryByText(/server error/)).not.toBeInTheDocument()
);
vi.runOnlyPendingTimers();
await waitForElementToBeRemoved(() => screen.queryByText(/server error/));
expect(onSuccess).toHaveBeenCalledTimes(0);
});
// TODO: fix and re-enable this test.
// It depends on the timers; see TODO above.
xtest("network error", async () => {
const handleClose = jest.fn().mockName("handleClose");
const onSuccess = jest.fn().mockName("handleOpen");
test("network error", async () => {
const user = userEvent.setup();
const handleClose = vi.fn().mockName("handleClose");
const onSuccess = vi.fn().mockName("handleOpen");
renderWithCtx(
<Login open={true} onSuccess={onSuccess} handleClose={handleClose} />
);
await userEvent.type(screen.getByLabelText(/Username/), "network-error");
await userEvent.type(screen.getByLabelText(/Password/), "asdf{enter}");
await screen.findByText(/network error/);
await waitFor(() =>
expect(screen.queryByText(/network error/)).not.toBeInTheDocument()
);
await user.type(screen.getByLabelText(/Username/), "network-error");
await user.type(screen.getByLabelText(/Password/), "asdf{enter}");
// This is the text chosen by msw:
// https://github.com/mswjs/interceptors/blob/122a6533ce57d551dc3b59b3bb43a39026989b70/src/interceptors/fetch/index.ts#L187
await screen.findByText(/Failed to fetch/);
expect(onSuccess).toHaveBeenCalledTimes(0);
});

View File

@ -2,18 +2,21 @@
// 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 "@mui/material/Avatar";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogTitle from "@mui/material/DialogTitle";
import FormHelperText from "@mui/material/FormHelperText";
import { useTheme } from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import LoadingButton from "@mui/lab/LoadingButton";
import React, { useEffect } from "react";
import * as api from "./api";
import { useSnackbars } from "./snackbars";
import Box from "@mui/material/Box/Box";
import DialogContent from "@mui/material/DialogContent/DialogContent";
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
import Typography from "@mui/material/Typography/Typography";
import AccountCircle from "@mui/icons-material/AccountCircle";
import Lock from "@mui/icons-material/Lock";
interface Props {
open: boolean;
@ -47,7 +50,6 @@ interface Props {
* <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this.
*/
const Login = ({ open, onSuccess, handleClose }: Props) => {
const theme = useTheme();
const snackbars = useSnackbars();
// This is a simple uncontrolled form; use refs.
@ -111,38 +113,52 @@ const Login = ({ open, onSuccess, handleClose }: Props) => {
fullWidth={true}
>
<DialogTitle id="login-title">
<Avatar sx={{ backgroundColor: theme.palette.secondary.main }}>
<LockOutlinedIcon />
</Avatar>
Log in
Welcome back!
<Typography variant="body2">Please login to Moonfire NVR.</Typography>
</DialogTitle>
<form onSubmit={onSubmit}>
<TextField
id="username"
label="Username"
variant="filled"
required
autoComplete="username"
fullWidth
error={error != null}
inputRef={usernameRef}
/>
<TextField
id="password"
label="Password"
variant="filled"
type="password"
required
autoComplete="current-password"
fullWidth
error={error != null}
inputRef={passwordRef}
/>
{/* reserve space for an error; show when there's something to see */}
<FormHelperText>{error == null ? " " : error}</FormHelperText>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
id="username"
label="Username"
variant="outlined"
required
autoComplete="username"
fullWidth
error={error != null}
inputRef={usernameRef}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<AccountCircle />
</InputAdornment>
),
}}
/>
<TextField
id="password"
label="Password"
variant="outlined"
type="password"
required
autoComplete="current-password"
fullWidth
error={error != null}
inputRef={passwordRef}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
}}
/>
{/* reserve space for an error; show when there's something to see */}
<FormHelperText>{error == null ? " " : error}</FormHelperText>
</Box>
</DialogContent>
<DialogActions>
<LoadingButton
type="submit"

View File

@ -22,6 +22,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import IconButton from "@mui/material/IconButton";
import DeleteDialog from "./DeleteDialog";
import AddEditDialog from "./AddEditDialog";
import React from "react";
interface Props {
Frame: (props: FrameProps) => JSX.Element;

View File

@ -40,18 +40,37 @@ async function myfetch(
): Promise<FetchResult<Response>> {
let response;
try {
response = await fetch(url, init);
// Note: `fetch` handles relative URLs on real browsers. Our test
// environment doesn't appear to simulate this properly. Even with the
// browser-like `jsdom` and `msw`'s interception, it uses node's native
// `Request`, which fails with a `TypeError` if given a relative URL.
// Resolve it ourselves here. Harmless in production, makes the tests work.
response = await fetch(new URL(url, window.location.origin), init);
} catch (e) {
if (!(e instanceof DOMException)) {
throw e;
}
if (e.name === "AbortError") {
return { status: "aborted" };
} else {
if (e instanceof TypeError) {
// One might expect this to indicate a logic flaw, but it can happen on a variety of
// conditions including network errors (as seen in Chrome, Firefox, and msw):
// <https://developer.mozilla.org/en-US/docs/Web/API/fetch#exceptions>
//
// It turns out the `DOMException` name of `NetworkEror` is just a decoy, I guess?
// Or possibly only used by the older `XMLHTTPRequest`.
// <https://webidl.spec.whatwg.org/#idl-DOMException-error-names>
// <https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names>
// <https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/send>
return {
status: "error",
message: `network error: ${e.message}`,
message: `${e.name}: ${e.message}`,
};
} else if (e instanceof DOMException) {
if (e.name === "AbortError") {
return { status: "aborted" };
}
return {
status: "error",
message: `${e.name}: ${e.message}`,
};
} else {
throw e;
}
}
if (!response.ok) {
@ -306,6 +325,16 @@ export async function deleteUser(
});
}
export interface RecordingSpecifier {
startId: number;
endId?: number;
firstUncommitted?: number;
growing?: boolean;
openId: number;
startTime90k: number;
endTime90k: number;
}
/**
* Represents a range of one or more recordings as in a single array entry of
* <tt>GET /api/cameras/&lt;uuid>/&lt;stream>/&lt;recordings></tt>.
@ -320,6 +349,9 @@ export interface Recording {
*/
endId?: number;
/** id of the first recording in this run. */
runStartId: number;
/**
* If this range is not fully committed to the database, the first id that is
* uncommitted. This is significant because it's possible that after a crash
@ -373,6 +405,11 @@ export interface Recording {
* the number of bytes of video in this recording.
*/
sampleFileBytes: number;
/**
* the reason this recording ended, if any/known.
*/
endReason?: string;
}
export interface VideoSampleEntry {
@ -440,7 +477,7 @@ export async function recordings(req: RecordingsRequest, init: RequestInit) {
export function recordingUrl(
cameraUuid: string,
stream: StreamType,
r: Recording,
r: RecordingSpecifier,
timestampTrack: boolean,
trimToRange90k?: [number, number]
): string {

View File

@ -0,0 +1,100 @@
// 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 "@mui/material/AppBar";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import ListIcon from "@mui/icons-material/List";
import PeopleIcon from "@mui/icons-material/People";
import Videocam from "@mui/icons-material/Videocam";
import * as api from "../api";
import MoonfireMenu from "../AppMenu";
import { useReducer } from "react";
import { LoginState } from "../App";
import { Link } from "react-router-dom";
export default function Header({
loginState,
logout,
setChangePasswordOpen,
activityMenuPart,
setLoginState,
toplevel,
}: {
loginState: LoginState;
logout: () => void;
setChangePasswordOpen: React.Dispatch<React.SetStateAction<boolean>>;
activityMenuPart?: JSX.Element;
setLoginState: React.Dispatch<React.SetStateAction<LoginState>>;
toplevel: api.ToplevelResponse | null;
}) {
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
return (
<>
<AppBar position="sticky">
<MoonfireMenu
loginState={loginState}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
changePassword={() => setChangePasswordOpen(true)}
menuClick={toggleShowMenu}
activityMenuPart={activityMenuPart}
/>
</AppBar>
<Drawer
variant="temporary"
open={showMenu}
onClose={toggleShowMenu}
ModalProps={{
keepMounted: true,
}}
>
<List>
<ListItemButton
key="list"
onClick={toggleShowMenu}
component={Link}
to="/"
>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List view" />
</ListItemButton>
<ListItemButton
key="live"
onClick={toggleShowMenu}
component={Link}
to="/live"
>
<ListItemIcon>
<Videocam />
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItemButton>
{toplevel?.permissions.adminUsers && (
<ListItemButton
key="users"
onClick={toggleShowMenu}
component={Link}
to="/users"
>
<ListItemIcon>
<PeopleIcon />
</ListItemIcon>
<ListItemText primary="Users" />
</ListItemButton>
)}
</List>
</Drawer>
</>
);
}

View File

@ -0,0 +1,75 @@
// 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
/* eslint-disable no-unused-vars */
import { useColorScheme } from "@mui/material/styles";
import React, { createContext } from "react";
export enum CurrentMode {
Auto = 0,
Light = 1,
Dark = 2,
}
interface ThemeProps {
changeTheme: () => void;
currentTheme: "dark" | "light";
choosenTheme: CurrentMode;
}
export const ThemeContext = createContext<ThemeProps>({
currentTheme: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
changeTheme: () => {},
choosenTheme: CurrentMode.Auto,
});
const ThemeMode = ({ children }: { children: JSX.Element }): JSX.Element => {
const { mode, setMode } = useColorScheme();
const useMediaQuery = (query: string) => {
const [matches, setMatches] = React.useState(
() => window.matchMedia(query).matches
);
React.useEffect(() => {
const m = window.matchMedia(query);
const l = () => setMatches(m.matches);
m.addEventListener("change", l);
return () => m.removeEventListener("change", l);
}, [query]);
return matches;
};
const detectedSystemColorScheme = useMediaQuery(
"(prefers-color-scheme: dark)"
)
? "dark"
: "light";
const changeTheme = React.useCallback(() => {
setMode(mode === "dark" ? "light" : mode === "light" ? "system" : "dark");
}, [mode, setMode]);
const currentTheme =
mode === "system"
? detectedSystemColorScheme
: mode ?? detectedSystemColorScheme;
const choosenTheme =
mode === "dark"
? CurrentMode.Dark
: mode === "light"
? CurrentMode.Light
: CurrentMode.Auto;
return (
<ThemeContext.Provider value={{ changeTheme, currentTheme, choosenTheme }}>
{children}
</ThemeContext.Provider>
);
};
export default ThemeMode;
export const useThemeMode = () => React.useContext(ThemeContext);

View File

@ -4,9 +4,23 @@
* SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
*/
:root {
--mui-palette-AppBar-darkBg: #000 !important;
--mui-palette-primary-main: #000 !important;
--mui-palette-secondary-main: #e65100 !important;
}
html,
body,
#root {
width: 100%;
height: 100%;
}
a {
color: inherit;
}
[data-mui-color-scheme="dark"] {
color-scheme: dark;
}

View File

@ -3,7 +3,10 @@
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import {
Experimental_CssVarsProvider,
experimental_extendTheme,
} from "@mui/material/styles";
import StyledEngineProvider from "@mui/material/StyledEngineProvider";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import "@fontsource/roboto";
@ -15,35 +18,43 @@ import { SnackbarProvider } from "./snackbars";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import "./index.css";
import { HashRouter } from "react-router-dom";
import ThemeMode from "./components/ThemeMode";
const theme = createTheme({
palette: {
primary: {
main: "#000000",
},
secondary: {
main: "#e65100",
const themeExtended = experimental_extendTheme({
colorSchemes: {
dark: {
palette: {
primary: {
main: "#000000",
},
secondary: {
main: "#e65100",
},
},
},
},
});
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(
<React.StrictMode>
<StyledEngineProvider injectFirst>
<CssBaseline />
<ThemeProvider theme={theme}>
<ErrorBoundary>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<SnackbarProvider autoHideDuration={5000}>
<HashRouter>
<App />
</HashRouter>
</SnackbarProvider>
</LocalizationProvider>
</ErrorBoundary>
</ThemeProvider>
{/* <ThemeProvider theme={theme}> */}
<Experimental_CssVarsProvider defaultMode="system" theme={themeExtended}>
<CssBaseline />
<ThemeMode>
<ErrorBoundary>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<SnackbarProvider autoHideDuration={5000}>
<HashRouter>
<App />
</HashRouter>
</SnackbarProvider>
</LocalizationProvider>
</ErrorBoundary>
</ThemeMode>
</Experimental_CssVarsProvider>
{/* </ThemeProvider> */}
</StyledEngineProvider>
</React.StrictMode>
);

View File

@ -1,43 +0,0 @@
// 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",
// Note: the `/api` here seems redundant with that above, but without it, the
// `ws: true` here appears to break react-script's automatic websocket reloading.
createProxyMiddleware("/api", {
target: process.env.PROXY_TARGET || "http://localhost:8080/",
ws: true,
// XXX: this doesn't appear to work for websocket requests. See
// <https://github.com/scottlamb/moonfire-nvr/issues/290>
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("; ");
});
}
},
})
);
};

View File

@ -7,18 +7,18 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
import { TextDecoder } from "util";
import { vi } from "vitest";
// LiveCamera/parser.ts uses TextDecoder, which works fine from the browser
// but isn't available from node.js without a little help.
// https://create-react-app.dev/docs/running-tests/#initializing-test-environment
// https://stackoverflow.com/questions/51090515/global-functions-in-typescript-for-jest-testing#comment89270564_51091150
declare let global: any;
// TODO: There's likely an elegant way to add TextDecoder to global's type.
// Some promising links:
// https://www.typescriptlang.org/docs/handbook/declaration-merging.html#global-augmentation
// https://stackoverflow.com/a/62011156/23584
// https://github.com/facebook/create-react-app/issues/6553#issuecomment-475491096
global.TextDecoder = TextDecoder;
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

View File

@ -2,15 +2,18 @@
// 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, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { SnackbarProvider, useSnackbars } from "./snackbars";
import { beforeEach, afterEach, expect, test, vi } from "vitest";
// Mock out timers.
beforeEach(() => jest.useFakeTimers());
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
test("notifications that time out", async () => {
@ -34,24 +37,24 @@ test("notifications that time out", async () => {
expect(screen.queryByText(/message B/)).not.toBeInTheDocument();
// ...then start to close...
jest.advanceTimersByTime(5000);
act(() => vi.advanceTimersByTime(5000));
expect(screen.getByText(/message A/)).toBeInTheDocument();
expect(screen.queryByText(/message B/)).not.toBeInTheDocument();
// ...then it should close and message B should open...
jest.runOnlyPendingTimers();
act(() => vi.runOnlyPendingTimers());
await waitFor(() =>
expect(screen.queryByText(/message A/)).not.toBeInTheDocument()
);
expect(screen.getByText(/message B/)).toBeInTheDocument();
// ...then message B should start to close...
jest.advanceTimersByTime(5000);
act(() => vi.advanceTimersByTime(5000));
expect(screen.queryByText(/message A/)).not.toBeInTheDocument();
expect(screen.getByText(/message B/)).toBeInTheDocument();
// ...then message B should fully close.
jest.runOnlyPendingTimers();
act(() => vi.runOnlyPendingTimers());
expect(screen.queryByText(/message A/)).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByText(/message B/)).not.toBeInTheDocument()

View File

@ -6,6 +6,7 @@ import { createTheme, ThemeProvider } from "@mui/material/styles";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { SnackbarProvider } from "./snackbars";
import React from "react";
export function renderWithCtx(
children: React.ReactElement

View File

@ -1,21 +1,24 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"downlevelIteration": true,
"allowJs": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
ui/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

61
ui/vite.config.ts Normal file
View File

@ -0,0 +1,61 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import viteCompression from "vite-plugin-compression";
import viteLegacyPlugin from "@vitejs/plugin-legacy";
const target = process.env.PROXY_TARGET ?? "http://localhost:8080/";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
viteCompression(),
viteLegacyPlugin({
targets: ["defaults", "fully supports es6-module"],
}),
],
server: {
proxy: {
"/api": {
target,
// Moonfire NVR needs WebSocket connections for live connections (and
// likely more in the future:
// <https://github.com/scottlamb/moonfire-nvr/issues/40>.)
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.
configure: (proxy, options) => {
// The `changeOrigin` above doesn't appear to apply to websocket
// requests. This has a similar effect.
proxy.on("proxyReqWs", (proxyReq, req, socket, options, head) => {
proxyReq.setHeader("origin", target);
});
proxy.on("proxyRes", (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("; ");
});
}
});
},
},
},
},
});

18
ui/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
// This avoids node's native fetch from causing vitest workers to hang
// and use 100% CPU.
// <https://github.com/vitest-dev/vitest/issues/3077#issuecomment-1815767839>
pool: "forks",
},
});