Compare commits
65 Commits
Author | SHA1 | Date |
---|---|---|
Scott Lamb | 0422593ec6 | |
Scott Lamb | adf73a2da1 | |
Scott Lamb | c20c644747 | |
Scott Lamb | 6c227ec0f5 | |
Scott Lamb | e6c7b800fe | |
Scott Lamb | 1ae61b4c64 | |
Scott Lamb | eb97e618fd | |
Scott Lamb | 93a9ad9af3 | |
Scott Lamb | 9acb095a5d | |
Scott Lamb | 8b5f2b4b0d | |
Scott Lamb | a65994ba71 | |
Scott Lamb | ef98f60241 | |
Scott Lamb | 7f4b04ee8a | |
michioxd | 9ede361b25 | |
michioxd | 8036aa40b7 | |
michioxd | a787703a31 | |
michioxd | 3f4cee7ead | |
michioxd | c67a5ffba5 | |
michioxd | 305deaa1e7 | |
michioxd | 29cafc2f82 | |
michioxd | 60c6247ef9 | |
michioxd | 317b8e9484 | |
michioxd | 91e02eba7a | |
michioxd | 5b5822900d | |
michioxd | b46d3acabb | |
michioxd | 6e81b27d1a | |
Scott Lamb | dbf6c2f476 | |
Scott Lamb | eef18372cc | |
Scott Lamb | 1f7c4c184a | |
Scott Lamb | f385215d6e | |
dependabot[bot] | f3da22fc5c | |
dependabot[bot] | 65b3d54466 | |
dependabot[bot] | 7beff8e1c9 | |
Scott Lamb | 9592fe24e8 | |
Scott Lamb | b47310644d | |
Scott Lamb | 6f472256ab | |
Scott Lamb | d1c033b46d | |
Scott Lamb | 223da03e36 | |
Scott Lamb | 4d4d786cde | |
Scott Lamb | 86816e862a | |
Scott Lamb | 2bcee02ea6 | |
Scott Lamb | 77720a09e3 | |
Scott Lamb | 38eba846f8 | |
Scott Lamb | 2da459dae2 | |
Scott Lamb | cca430b701 | |
Scott Lamb | 7d12e8033d | |
Scott Lamb | e9a25322b5 | |
Scott Lamb | 3911334fee | |
Scott Lamb | 24880a5c2d | |
Scott Lamb | 79af39f35e | |
Scott Lamb | 14d1879ccd | |
Scott Lamb | 3de62eb70d | |
Scott Lamb | f493ad94eb | |
Scott Lamb | 672647730d | |
Scott Lamb | 1013c35791 | |
Scott Lamb | c5f5bd39ee | |
Scott Lamb | 882596c7f6 | |
Scott Lamb | 8efea4526d | |
Scott Lamb | 5b1f7807f9 | |
Scott Lamb | 1f7806108c | |
Leandro Silva | 5e00217784 | |
Leandro Silva | 5ea5d27908 | |
Scott Lamb | 4a0cb6e62d | |
Scott Lamb | a2d243d3a4 | |
Scott Lamb | 89ee2d0269 |
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
3
AUTHORS
3
AUTHORS
|
@ -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>
|
||||
|
|
67
CHANGELOG.md
67
CHANGELOG.md
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
135
guide/install.md
135
guide/install.md
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]]`:
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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" }
|
|
@ -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"] }
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:?}"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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])?;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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) =
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
)?;
|
||||
|
|
|
@ -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.
|
||||
{
|
||||
|
|
|
@ -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)| {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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.
|
||||
};
|
||||
|
||||
|
|
|
@ -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(())
|
||||
})?;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -9,9 +9,12 @@
|
|||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
# production, current path
|
||||
/build
|
||||
|
||||
# production, old path
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
|
|
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
133
ui/package.json
133
ui/package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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 />);
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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`}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/<uuid>/<stream>/<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 {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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("; ");
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
|
@ -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("; ");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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",
|
||||
},
|
||||
});
|
Loading…
Reference in New Issue