visual improvements to list UI, tests
In particular there are fewer reflows while loading.
This commit is contained in:
parent
1a5e01adef
commit
93b0db4c28
|
@ -59,7 +59,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
|||
if (!foundAny) {
|
||||
for (const c of cameras) {
|
||||
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);
|
||||
let foundAny = false;
|
||||
for (const st in c.streams) {
|
||||
const s = c.streams[st as StreamType];
|
||||
const s = c.streams[st as StreamType]!;
|
||||
if (selected.has(s)) {
|
||||
updated.delete(s);
|
||||
foundAny = true;
|
||||
|
@ -77,7 +77,7 @@ const StreamMultiSelector = ({ cameras, selected, setSelected }: Props) => {
|
|||
}
|
||||
if (!foundAny) {
|
||||
for (const st in c.streams) {
|
||||
updated.add(c.streams[st as StreamType]);
|
||||
updated.add(c.streams[st as StreamType]!);
|
||||
}
|
||||
}
|
||||
setSelected(updated);
|
||||
|
|
|
@ -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/);
|
||||
});
|
|
@ -2,14 +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 CircularProgress from "@material-ui/core/CircularProgress";
|
||||
import React from "react";
|
||||
import * as api from "../api";
|
||||
import { useSnackbars } from "../snackbars";
|
||||
import { Stream } from "../types";
|
||||
import TableBody from "@material-ui/core/TableBody";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import TableRow, { TableRowProps } from "@material-ui/core/TableRow";
|
||||
import Skeleton from "@material-ui/core/Skeleton";
|
||||
import Alert from "@material-ui/core/Alert";
|
||||
|
||||
interface Props {
|
||||
stream: Stream;
|
||||
|
@ -26,13 +27,60 @@ const sizeFmt = new Intl.NumberFormat([], {
|
|||
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
|
||||
* <tt>stream</tt> and <tt>range90k</tt>.
|
||||
* Creates a <tt>TableHeader</tt> and <tt>TableBody</tt> with a list of videos
|
||||
* 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.
|
||||
*
|
||||
* When one is clicked, calls <tt>setActiveRecording</tt>.
|
||||
* When a video is clicked, calls <tt>setActiveRecording</tt>.
|
||||
*/
|
||||
const VideoList = ({
|
||||
stream,
|
||||
|
@ -41,89 +89,104 @@ const VideoList = ({
|
|||
formatTime,
|
||||
}: Props) => {
|
||||
const snackbars = useSnackbars();
|
||||
const [
|
||||
response,
|
||||
setResponse,
|
||||
] = React.useState<api.FetchResult<api.RecordingsResponse> | null>(null);
|
||||
const [showLoading, setShowLoading] = React.useState(false);
|
||||
const [state, setState] = React.useState<State | null>(null);
|
||||
React.useEffect(() => {
|
||||
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 = {
|
||||
cameraUuid: stream.camera.uuid,
|
||||
stream: stream.streamType,
|
||||
startTime90k: range90k[0],
|
||||
endTime90k: range90k[1],
|
||||
};
|
||||
let r = await api.recordings(req, { signal });
|
||||
if (r.status === "success") {
|
||||
let response = await api.recordings(req, { signal });
|
||||
if (response.status === "success") {
|
||||
// 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) {
|
||||
doFetch(abort.signal, range90k);
|
||||
const timeout = setTimeout(() => setShowLoading(true), 1000);
|
||||
const timerId = setTimeout(
|
||||
() => setState({ range90k, response: { status: "skeleton" } }),
|
||||
1000
|
||||
);
|
||||
doFetch(abort.signal, timerId, range90k);
|
||||
return () => {
|
||||
abort.abort();
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
} else {
|
||||
setResponse(null);
|
||||
}
|
||||
}, [range90k, snackbars, stream]);
|
||||
|
||||
let body = null;
|
||||
if (response === null) {
|
||||
if (showLoading) {
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<CircularProgress />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
} else if (response.status === "error") {
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
let body;
|
||||
if (state.response.status === "skeleton") {
|
||||
body = (
|
||||
<Row
|
||||
role="progressbar"
|
||||
start={<Skeleton />}
|
||||
end={<Skeleton />}
|
||||
resolution={<Skeleton />}
|
||||
fps={<Skeleton />}
|
||||
storage={<Skeleton />}
|
||||
bitrate={<Skeleton />}
|
||||
/>
|
||||
);
|
||||
} else if (state.response.status === "error") {
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>Error: {response.status}</TableCell>
|
||||
<TableCell colSpan={6}>
|
||||
<Alert severity="error">{state.response.message}</Alert>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} else if (response.status === "success") {
|
||||
const resp = response.response;
|
||||
} else if (state.response.status === "success") {
|
||||
const resp = state.response.response;
|
||||
body = resp.recordings.map((r: api.Recording) => {
|
||||
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
|
||||
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
|
||||
const rate = (r.sampleFileBytes / durationSec) * 0.000008;
|
||||
return (
|
||||
<TableRow
|
||||
<Row
|
||||
key={r.startId}
|
||||
className="recording"
|
||||
onClick={() => setActiveRecording([stream, r])}
|
||||
>
|
||||
<TableCell align="right">
|
||||
{formatTime(Math.max(r.startTime90k, range90k![0]))}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatTime(Math.min(r.endTime90k, range90k![1]))}
|
||||
</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>
|
||||
start={formatTime(Math.max(r.startTime90k, state.range90k[0]))}
|
||||
end={formatTime(Math.min(r.endTime90k, state.range90k[1]))}
|
||||
resolution={`${vse.width}x${vse.height}`}
|
||||
fps={frameRateFmt.format(r.videoSamples / durationSec)}
|
||||
storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
|
||||
bitrate={`${sizeFmt.format(rate)} Mbps`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
|
|
@ -11,10 +11,7 @@ import Modal from "@material-ui/core/Modal";
|
|||
import format from "date-fns/format";
|
||||
import utcToZonedTime from "date-fns-tz/utcToZonedTime";
|
||||
import Table from "@material-ui/core/Table";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableContainer from "@material-ui/core/TableContainer";
|
||||
import TableHead from "@material-ui/core/TableHead";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import StreamMultiSelector from "./StreamMultiSelector";
|
||||
import TimerangeSelector from "./TimerangeSelector";
|
||||
|
@ -36,19 +33,20 @@ const useStyles = makeStyles((theme: Theme) => ({
|
|||
height: "100%",
|
||||
background: "#000",
|
||||
},
|
||||
camera: {
|
||||
background: theme.palette.primary.light,
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
videoTable: {
|
||||
flexGrow: 1,
|
||||
width: "max-content",
|
||||
height: "max-content",
|
||||
"& .streamHeader": {
|
||||
background: theme.palette.primary.light,
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
"& .MuiTableBody-root:not(:last-child):after": {
|
||||
content: "''",
|
||||
display: "block",
|
||||
height: theme.spacing(2),
|
||||
},
|
||||
"& tbody tr": {
|
||||
"& tbody .recording": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
"& .opt": {
|
||||
|
@ -95,35 +93,13 @@ const Main = ({ cameras, timeZoneName }: Props) => {
|
|||
let videoLists = [];
|
||||
for (const s of selectedStreams) {
|
||||
videoLists.push(
|
||||
<React.Fragment key={`${s.camera.uuid}-${s.streamType}`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className={classes.camera}>
|
||||
{s.camera.shortName} {s.streamType}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell 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>
|
||||
<VideoList
|
||||
key={`${s.camera.uuid}-${s.streamType}`}
|
||||
stream={s}
|
||||
range90k={range90k}
|
||||
setActiveRecording={setActiveRecording}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const closeModal = (event: {}, reason: string) => {
|
||||
|
|
|
@ -140,7 +140,7 @@ export async function toplevel(init: RequestInit) {
|
|||
if (resp.status === "success") {
|
||||
resp.response.cameras.forEach((c) => {
|
||||
for (const key in c.streams) {
|
||||
const s = c.streams[key as StreamType];
|
||||
const s = c.streams[key as StreamType]!;
|
||||
s.camera = c;
|
||||
s.streamType = key as StreamType;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export interface Camera {
|
|||
uuid: string;
|
||||
shortName: string;
|
||||
description: string;
|
||||
streams: Record<StreamType, Stream>;
|
||||
streams: Partial<Record<StreamType, Stream>>;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
|
|
Loading…
Reference in New Issue