visual improvements to list UI, tests

In particular there are fewer reflows while loading.
This commit is contained in:
Scott Lamb 2021-03-15 22:45:21 -07:00
parent 1a5e01adef
commit 93b0db4c28
6 changed files with 314 additions and 99 deletions

View File

@ -59,7 +59,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
if (!foundAny) { if (!foundAny) {
for (const c of cameras) { for (const c of cameras) {
if (c.streams[st] !== undefined) { if (c.streams[st] !== undefined) {
updated.add(c.streams[st]); updated.add(c.streams[st as StreamType]!);
} }
} }
} }
@ -69,7 +69,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
const updated = new Set(selected); const updated = new Set(selected);
let foundAny = false; let foundAny = false;
for (const st in c.streams) { for (const st in c.streams) {
const s = c.streams[st as StreamType]; const s = c.streams[st as StreamType]!;
if (selected.has(s)) { if (selected.has(s)) {
updated.delete(s); updated.delete(s);
foundAny = true; foundAny = true;
@ -77,7 +77,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
} }
if (!foundAny) { if (!foundAny) {
for (const st in c.streams) { for (const st in c.streams) {
updated.add(c.streams[st as StreamType]); updated.add(c.streams[st as StreamType]!);
} }
} }
setSelected(updated); setSelected(updated);

View File

@ -0,0 +1,176 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { screen } from "@testing-library/react";
import { utcToZonedTime } from "date-fns-tz";
import format from "date-fns/format";
import { rest } 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";
const TEST_CAMERA: Camera = {
uuid: "c7278ba0-a001-420c-911e-fff4e33f6916",
shortName: "test-camera",
description: "",
streams: {},
};
const TEST_STREAM: Stream = {
camera: TEST_CAMERA,
streamType: "main",
retainBytes: 0,
minStartTime90k: 0,
maxEndTime90k: 0,
totalDuration90k: 0,
totalSampleFileBytes: 0,
fsBytes: 0,
days: {},
};
const TEST_RANGE1: [number, number] = [
145747836000000, // 2021-04-26T00:00:00:00000-07:00
145755612000000, // 2021-04-27T00:00:00:00000-07:00
];
const TEST_RANGE2: [number, number] = [
145755612000000, // 2021-04-27T00:00:00:00000-07:00
145763388000000, // 2021-04-28T00:00:00:00000-07:00
];
const TEST_RECORDINGS1: Recording[] = [
{
startId: 42,
openId: 1,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
];
const TEST_RECORDINGS2: Recording[] = [
{
startId: 42,
openId: 1,
startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00
endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
];
const TEST_VIDEO_SAMPLE_ENTRIES: { [id: number]: VideoSampleEntry } = {
4: {
width: 1920,
height: 1080,
},
};
function TestFormat(time90k: number) {
return format(
utcToZonedTime(new Date(time90k / 90), "America/Los_Angeles"),
"d MMM yyyy HH:mm:ss"
);
}
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({
recordings: TEST_RECORDINGS1,
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
})
);
} else {
return res(
ctx.delay(2000), // 2 second delay
ctx.json({
recordings: TEST_RECORDINGS2,
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
})
);
}
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("load", async () => {
renderWithCtx(
<table>
<VideoList
stream={TEST_STREAM}
range90k={TEST_RANGE1}
setActiveRecording={() => {}}
formatTime={TestFormat}
/>
</table>
);
expect(await screen.findByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
});
// This test may be slightly flaky because it uses real timers. It looks like
// msw specifically avoids using test timers:
// https://github.com/mswjs/msw/pull/243
test("slow replace", async () => {
const { rerender } = renderWithCtx(
<table>
<VideoList
stream={TEST_STREAM}
range90k={TEST_RANGE1}
setActiveRecording={() => {}}
formatTime={TestFormat}
/>
</table>
);
expect(await screen.findByText(/26 Apr 2021 08:21:13/)).toBeInTheDocument();
rerender(
<table>
<VideoList
stream={TEST_STREAM}
range90k={TEST_RANGE2}
setActiveRecording={() => {}}
formatTime={TestFormat}
/>
</table>
);
// The first results don't go away immediately.
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();
// Then the second query result should show up.
expect(await screen.findByText(/27 Apr 2021 06:17:43/)).toBeInTheDocument();
});
test("error", async () => {
renderWithCtx(
<table>
<VideoList
stream={TEST_STREAM}
range90k={[42, 64]}
setActiveRecording={() => {}}
formatTime={TestFormat}
/>
</table>
);
expect(await screen.findByRole("alert")).toHaveTextContent(/server error/);
});

View File

@ -2,14 +2,15 @@
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import CircularProgress from "@material-ui/core/CircularProgress";
import React from "react"; import React from "react";
import * as api from "../api"; import * as api from "../api";
import { useSnackbars } from "../snackbars"; import { useSnackbars } from "../snackbars";
import { Stream } from "../types"; import { Stream } from "../types";
import TableBody from "@material-ui/core/TableBody"; import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell"; import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow"; import TableRow, { TableRowProps } from "@material-ui/core/TableRow";
import Skeleton from "@material-ui/core/Skeleton";
import Alert from "@material-ui/core/Alert";
interface Props { interface Props {
stream: Stream; stream: Stream;
@ -26,13 +27,60 @@ const sizeFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 1, maximumFractionDigits: 1,
}); });
interface State {
/**
* The range to display.
* During loading, this can differ from the requested range.
*/
range90k: [number, number];
response: { status: "skeleton" } | api.FetchResult<api.RecordingsResponse>;
}
interface RowProps extends TableRowProps {
start: React.ReactNode;
end: React.ReactNode;
resolution: React.ReactNode;
fps: React.ReactNode;
storage: React.ReactNode;
bitrate: React.ReactNode;
}
const Row = ({
start,
end,
resolution,
fps,
storage,
bitrate,
...rest
}: RowProps) => (
<TableRow {...rest}>
<TableCell align="right">{start}</TableCell>
<TableCell align="right">{end}</TableCell>
<TableCell align="right" className="opt">
{resolution}
</TableCell>
<TableCell align="right" className="opt">
{fps}
</TableCell>
<TableCell align="right" className="opt">
{storage}
</TableCell>
<TableCell align="right">{bitrate}</TableCell>
</TableRow>
);
/** /**
* Creates a <tt>TableBody</tt> with a list of videos for a given * Creates a <tt>TableHeader</tt> and <tt>TableBody</tt> with a list of videos
* <tt>stream</tt> and <tt>range90k</tt>. * for a given <tt>stream</tt> and <tt>range90k</tt>.
*
* Attempts to minimize reflows while loading. It leaves the existing content
* (either nothing or a previous range) for a while before displaying a
* skeleton.
* *
* The parent is responsible for creating the greater table. * The parent is responsible for creating the greater table.
* *
* When one is clicked, calls <tt>setActiveRecording</tt>. * When a video is clicked, calls <tt>setActiveRecording</tt>.
*/ */
const VideoList = ({ const VideoList = ({
stream, stream,
@ -41,89 +89,104 @@ const VideoList = ({
formatTime, formatTime,
}: Props) => { }: Props) => {
const snackbars = useSnackbars(); const snackbars = useSnackbars();
const [ const [state, setState] = React.useState<State | null>(null);
response,
setResponse,
] = React.useState<api.FetchResult<api.RecordingsResponse> | null>(null);
const [showLoading, setShowLoading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
const abort = new AbortController(); const abort = new AbortController();
const doFetch = async (signal: AbortSignal, range90k: [number, number]) => { const doFetch = async (
signal: AbortSignal,
timerId: ReturnType<typeof setTimeout>,
range90k: [number, number]
) => {
const req: api.RecordingsRequest = { const req: api.RecordingsRequest = {
cameraUuid: stream.camera.uuid, cameraUuid: stream.camera.uuid,
stream: stream.streamType, stream: stream.streamType,
startTime90k: range90k[0], startTime90k: range90k[0],
endTime90k: range90k[1], endTime90k: range90k[1],
}; };
let r = await api.recordings(req, { signal }); let response = await api.recordings(req, { signal });
if (r.status === "success") { if (response.status === "success") {
// Sort recordings in descending order by start time. // Sort recordings in descending order by start time.
r.response.recordings.sort((a, b) => b.startId - a.startId); response.response.recordings.sort((a, b) => b.startId - a.startId);
} }
setResponse(r); clearTimeout(timerId);
setState({ range90k, response });
}; };
if (range90k !== null) { if (range90k !== null) {
doFetch(abort.signal, range90k); const timerId = setTimeout(
const timeout = setTimeout(() => setShowLoading(true), 1000); () => setState({ range90k, response: { status: "skeleton" } }),
1000
);
doFetch(abort.signal, timerId, range90k);
return () => { return () => {
abort.abort(); abort.abort();
clearTimeout(timeout); clearTimeout(timerId);
}; };
} else {
setResponse(null);
} }
}, [range90k, snackbars, stream]); }, [range90k, snackbars, stream]);
let body = null; if (state === null) {
if (response === null) { return null;
if (showLoading) { }
body = ( let body;
<TableRow> if (state.response.status === "skeleton") {
<TableCell colSpan={6}> body = (
<CircularProgress /> <Row
</TableCell> role="progressbar"
</TableRow> start={<Skeleton />}
); end={<Skeleton />}
} resolution={<Skeleton />}
} else if (response.status === "error") { fps={<Skeleton />}
storage={<Skeleton />}
bitrate={<Skeleton />}
/>
);
} else if (state.response.status === "error") {
body = ( body = (
<TableRow> <TableRow>
<TableCell colSpan={6}>Error: {response.status}</TableCell> <TableCell colSpan={6}>
<Alert severity="error">{state.response.message}</Alert>
</TableCell>
</TableRow> </TableRow>
); );
} else if (response.status === "success") { } else if (state.response.status === "success") {
const resp = response.response; const resp = state.response.response;
body = resp.recordings.map((r: api.Recording) => { body = resp.recordings.map((r: api.Recording) => {
const vse = resp.videoSampleEntries[r.videoSampleEntryId]; const vse = resp.videoSampleEntries[r.videoSampleEntryId];
const durationSec = (r.endTime90k - r.startTime90k) / 90000; const durationSec = (r.endTime90k - r.startTime90k) / 90000;
const rate = (r.sampleFileBytes / durationSec) * 0.000008;
return ( return (
<TableRow <Row
key={r.startId} key={r.startId}
className="recording"
onClick={() => setActiveRecording([stream, r])} onClick={() => setActiveRecording([stream, r])}
> start={formatTime(Math.max(r.startTime90k, state.range90k[0]))}
<TableCell align="right"> end={formatTime(Math.min(r.endTime90k, state.range90k[1]))}
{formatTime(Math.max(r.startTime90k, range90k![0]))} resolution={`${vse.width}x${vse.height}`}
</TableCell> fps={frameRateFmt.format(r.videoSamples / durationSec)}
<TableCell align="right"> storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
{formatTime(Math.min(r.endTime90k, range90k![1]))} bitrate={`${sizeFmt.format(rate)} Mbps`}
</TableCell> />
<TableCell className="opt" align="right">
{vse.width}x{vse.height}
</TableCell>
<TableCell className="opt" align="right">
{frameRateFmt.format(r.videoSamples / durationSec)}
</TableCell>
<TableCell className="opt" align="right">
{sizeFmt.format(r.sampleFileBytes / 1048576)} MiB
</TableCell>
<TableCell align="right">
{sizeFmt.format((r.sampleFileBytes / durationSec) * 0.000008)} Mbps
</TableCell>
</TableRow>
); );
}); });
} }
return <TableBody>{body}</TableBody>; return (
<TableBody>
<TableRow>
<TableCell colSpan={6} className="streamHeader">
{stream.camera.shortName} {stream.streamType}
</TableCell>
</TableRow>
<Row
start="start"
end="end"
resolution="resolution"
fps="fps"
storage="storage"
bitrate="bitrate"
/>
{body}
</TableBody>
);
}; };
export default VideoList; export default VideoList;

View File

@ -11,10 +11,7 @@ import Modal from "@material-ui/core/Modal";
import format from "date-fns/format"; import format from "date-fns/format";
import utcToZonedTime from "date-fns-tz/utcToZonedTime"; import utcToZonedTime from "date-fns-tz/utcToZonedTime";
import Table from "@material-ui/core/Table"; import Table from "@material-ui/core/Table";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer"; import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper"; import Paper from "@material-ui/core/Paper";
import StreamMultiSelector from "./StreamMultiSelector"; import StreamMultiSelector from "./StreamMultiSelector";
import TimerangeSelector from "./TimerangeSelector"; import TimerangeSelector from "./TimerangeSelector";
@ -36,19 +33,20 @@ const useStyles = makeStyles((theme: Theme) => ({
height: "100%", height: "100%",
background: "#000", background: "#000",
}, },
camera: {
background: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
},
videoTable: { videoTable: {
flexGrow: 1,
width: "max-content", width: "max-content",
height: "max-content", height: "max-content",
"& .streamHeader": {
background: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
},
"& .MuiTableBody-root:not(:last-child):after": { "& .MuiTableBody-root:not(:last-child):after": {
content: "''", content: "''",
display: "block", display: "block",
height: theme.spacing(2), height: theme.spacing(2),
}, },
"& tbody tr": { "& tbody .recording": {
cursor: "pointer", cursor: "pointer",
}, },
"& .opt": { "& .opt": {
@ -95,35 +93,13 @@ const Main = ({ cameras, timeZoneName }: Props) => {
let videoLists = []; let videoLists = [];
for (const s of selectedStreams) { for (const s of selectedStreams) {
videoLists.push( videoLists.push(
<React.Fragment key={`${s.camera.uuid}-${s.streamType}`}> <VideoList
<TableHead> key={`${s.camera.uuid}-${s.streamType}`}
<TableRow> stream={s}
<TableCell colSpan={6} className={classes.camera}> range90k={range90k}
{s.camera.shortName} {s.streamType} setActiveRecording={setActiveRecording}
</TableCell> formatTime={formatTime}
</TableRow> />
<TableRow>
<TableCell align="right">start</TableCell>
<TableCell align="right">end</TableCell>
<TableCell className="opt" align="right">
resolution
</TableCell>
<TableCell className="opt" align="right">
fps
</TableCell>
<TableCell className="opt" align="right">
storage
</TableCell>
<TableCell align="right">bitrate</TableCell>
</TableRow>
</TableHead>
<VideoList
stream={s}
range90k={range90k}
setActiveRecording={setActiveRecording}
formatTime={formatTime}
/>
</React.Fragment>
); );
} }
const closeModal = (event: {}, reason: string) => { const closeModal = (event: {}, reason: string) => {

View File

@ -140,7 +140,7 @@ export async function toplevel(init: RequestInit) {
if (resp.status === "success") { if (resp.status === "success") {
resp.response.cameras.forEach((c) => { resp.response.cameras.forEach((c) => {
for (const key in c.streams) { for (const key in c.streams) {
const s = c.streams[key as StreamType]; const s = c.streams[key as StreamType]!;
s.camera = c; s.camera = c;
s.streamType = key as StreamType; s.streamType = key as StreamType;
} }

View File

@ -18,7 +18,7 @@ export interface Camera {
uuid: string; uuid: string;
shortName: string; shortName: string;
description: string; description: string;
streams: Record<StreamType, Stream>; streams: Partial<Record<StreamType, Stream>>;
} }
export interface Stream { export interface Stream {