moonfire-nvr/ui-src/NVRApplication.js
Scott Lamb 45abeb22de overhaul HTTP serving and caching
* use content-hashed paths for static resources (except the top-level
  request), with immutable Cache-Control headers. This should improve
  cache behavior in both directions: avoid preventable HTTP requests and
  cause immediate refresh when needed. I had some staleness when
  browsing with my phone.

* set up the favicons properly while I'm at it (closes #50). I used the
  convenient favicons-webpack-plugin to build everything from a .svg.
  I've hit an error similar to lovell/sharp#1593 at least once though so
  I might change my mind about that part if it continues to be
  problematic.

* use http-serve's new directory traversal code for static file serving.
  This removes the odd behavior where files that weren't present at
  server startup couldn't be served. (I wasn't comfortable switching to
  the content-hashed paths before doing this.) It also means the static
  files can be served compressed. JSON API responses were already served
  compressed, so this closes #25.

* for a given API URL, decide if we want it to be cached or not
  server-side. Stop using jQuery's kludgy cache-defeating _=<timestamp>
  URL parameter. I might start setting etags on some of these things
  and could serve 304 Not Modified responses if it's genuinely
  unmodified.
2020-05-29 21:20:15 -07:00

409 lines
12 KiB
JavaScript

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