diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b2c1dd3..aedb2a23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,26 @@
# Changelog
All notable changes to this project will be documented in this file.
+# v1.1.16 - 2022-04-24
+
+## Notable Changes
+- **API:**
+ - New endpoint `/api/system/disk`
+- **Charts:**
+ - Added new chart `disk load`
+- **Widgets:**
+ - Added new widget to view the available and used disk space
+
+## Other Changes
+- Added disk space information to `Settings > Recordings`
+- Added check of storage space for motion events to avoid recording when storage space is low
+- Simplified `Add Camera` through UI
+- Minor UI improvements
+
+## Bugfixes
+- Fixed an issue where removing a camera via the user interface did not destroy the camera controller
+- Minor bugfixes
+
# v1.1.15 - 2022-04-24
## Notable Changes
diff --git a/README.md b/README.md
index 35844785..d36a3a0e 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ sudo npm install -g camera.ui@latest
- [Browser](#browser)
- [Supported Cameras](#supported-cameras)
- [Camera Settings](#camera-settings)
+ - [API](#api)
- [FAQ](#faq)
- [Contributing](#contributing)
- [Troubleshooting](#troubleshooting)
@@ -366,6 +367,14 @@ You should make the following configuration for your camera via the camera's own
* 25 FPS (30 FPS prefered).
* Keyframe interval is 4 seconds. Frame Interval = FPS * 4 => 30 * 4 = 120
+## API
+
+camera.ui has a REST API that is primarily used by the web client (i.e. the UI), but can also be consumed by other apps or personal scripts.
+
+You can access the API reference via your local instance by going to /swagger
+
+For example http://[IP]:8081/swagger
+
## FAQ
Please check our [FAQ](https://github.com/SeydX/camera.ui/wiki/FAQ) before you open an issue.
diff --git a/package-lock.json b/package-lock.json
index f46dbea3..c0375f31 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "camera.ui",
- "version": "1.1.15",
+ "version": "1.1.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "camera.ui",
- "version": "1.1.15",
+ "version": "1.1.16",
"funding": [
{
"type": "paypal",
@@ -29,6 +29,7 @@
"axios": "^0.26.1",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
+ "check-disk-space": "^3.3.0",
"commander": "6.2.1",
"compare-versions": "^4.1.3",
"connect-history-api-fallback": "^1.6.0",
@@ -37,6 +38,7 @@
"ffmpeg-for-homebridge": "0.0.9",
"fs-extra": "^10.1.0",
"ftp-srv": "^4.6.0",
+ "get-folder-size": "^3.1.0",
"got": "^12.0.3",
"helmet": "^5.0.2",
"ip": "^1.1.5",
@@ -3348,6 +3350,14 @@
"node": ">=10"
}
},
+ "node_modules/check-disk-space": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.3.0.tgz",
+ "integrity": "sha512-Hvr+Nr01xSSvuCpXvJ8oZ2iXjIu4XT3uHbw3g7F/Uiw6O5xk8c/Ot7ZGFDaTRDf2Bz8AdWA4DvpAgCJVKt8arw==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@@ -5532,6 +5542,11 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
+ "node_modules/gar": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz",
+ "integrity": "sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w=="
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5549,6 +5564,20 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-folder-size": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-3.1.0.tgz",
+ "integrity": "sha512-/I7q+x1HCd22IXP4+kp2Wkz8+au7VfNwNyMfM4Z0gwaTMs+dJ1ShXUWDGSWXi+rDU59MI/j7NBP7+kd7zejnPw==",
+ "dependencies": {
+ "gar": "^1.0.4"
+ },
+ "bin": {
+ "get-folder-size": "bin/get-folder-size.js"
+ },
+ "engines": {
+ "node": ">=14.13.0"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@@ -14065,6 +14094,11 @@
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
"dev": true
},
+ "check-disk-space": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.3.0.tgz",
+ "integrity": "sha512-Hvr+Nr01xSSvuCpXvJ8oZ2iXjIu4XT3uHbw3g7F/Uiw6O5xk8c/Ot7ZGFDaTRDf2Bz8AdWA4DvpAgCJVKt8arw=="
+ },
"chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@@ -15719,6 +15753,11 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
+ "gar": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz",
+ "integrity": "sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w=="
+ },
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -15730,6 +15769,14 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
+ "get-folder-size": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-3.1.0.tgz",
+ "integrity": "sha512-/I7q+x1HCd22IXP4+kp2Wkz8+au7VfNwNyMfM4Z0gwaTMs+dJ1ShXUWDGSWXi+rDU59MI/j7NBP7+kd7zejnPw==",
+ "requires": {
+ "gar": "^1.0.4"
+ }
+ },
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
diff --git a/package.json b/package.json
index 75fa5494..3cd78635 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "camera.ui",
- "version": "1.1.15",
+ "version": "1.1.16",
"description": "NVR like user interface for RTSP capable cameras.",
"author": "SeydX (https://github.com/SeydX/camera.ui)",
"scripts": {
@@ -22,6 +22,7 @@
"axios": "^0.26.1",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
+ "check-disk-space": "^3.3.0",
"commander": "6.2.1",
"compare-versions": "^4.1.3",
"connect-history-api-fallback": "^1.6.0",
@@ -30,6 +31,7 @@
"ffmpeg-for-homebridge": "0.0.9",
"fs-extra": "^10.1.0",
"ftp-srv": "^4.6.0",
+ "get-folder-size": "^3.1.0",
"got": "^12.0.3",
"helmet": "^5.0.2",
"ip": "^1.1.5",
diff --git a/src/api/components/system/system.controller.js b/src/api/components/system/system.controller.js
index 9748998f..1714c39e 100644
--- a/src/api/components/system/system.controller.js
+++ b/src/api/components/system/system.controller.js
@@ -166,6 +166,18 @@ export const getChangelog = async (req, res) => {
}
};
+export const getDiskLoad = async (req, res) => {
+ try {
+ await Socket.handleDiskUsage();
+ res.status(200).send(Socket.diskSpace);
+ } catch (error) {
+ res.status(500).send({
+ statusCode: 500,
+ message: error.message,
+ });
+ }
+};
+
export const getFtpServerStatus = async (req, res) => {
try {
const status = MotionController.ftpServer.server.listening;
diff --git a/src/api/components/system/system.routes.js b/src/api/components/system/system.routes.js
index b5b9b66a..671698d6 100644
--- a/src/api/components/system/system.routes.js
+++ b/src/api/components/system/system.routes.js
@@ -151,6 +151,28 @@ export const routesConfig = (app) => {
SystemController.getChangelog,
]);
+ /**
+ * @swagger
+ * /api/system/disk:
+ * get:
+ * tags: [System]
+ * security:
+ * - bearerAuth: []
+ * summary: Get system disk load
+ * responses:
+ * 200:
+ * description: Successfull
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Internal server error
+ */
+ app.get('/api/system/disk', [
+ ValidationMiddleware.validJWTNeeded,
+ PermissionMiddleware.onlyMasterCanDoThisAction,
+ SystemController.getDiskLoad,
+ ]);
+
/**
* @swagger
* /api/system/npm:
diff --git a/src/api/database.js b/src/api/database.js
index 18f80457..c9270b09 100644
--- a/src/api/database.js
+++ b/src/api/database.js
@@ -9,6 +9,8 @@ import piexif from 'piexifjs';
import webpush from 'web-push';
import { Low, JSONFile, MemorySync } from '@seydx/lowdb';
+import Socket from './socket.js';
+
import Cleartimer from '../common/cleartimer.js';
import ConfigService from '../services/config/config.service.js';
@@ -212,6 +214,8 @@ export default class Database {
LoggerService.notificationsDB = Database.notificationsDB;
+ Socket.watchSystem();
+
return {
interface: Database.interfaceDB,
tokens: Database.tokensDB,
diff --git a/src/api/socket.js b/src/api/socket.js
index caa69199..38341f1a 100644
--- a/src/api/socket.js
+++ b/src/api/socket.js
@@ -1,6 +1,8 @@
/* eslint-disable unicorn/explicit-length-check */
'use-strict';
+import checkDiskSpace from 'check-disk-space';
+import getFolderSize from 'get-folder-size';
import { Server } from 'socket.io';
import socketioJwt from 'socketio-jwt';
import systeminformation from 'systeminformation';
@@ -22,9 +24,18 @@ export default class Socket {
systemTime: '0m',
processTime: '0m',
};
+
static #cpuLoadHistory = [];
static #cpuTempHistory = [];
static #memoryUsageHistory = [];
+ static #diskSpaceHistory = [];
+
+ static diskSpace = {
+ free: null,
+ total: null,
+ used: null,
+ usedRecordings: null,
+ };
static io;
@@ -47,7 +58,7 @@ export default class Socket {
Socket.io.on('connection', async (socket) => {
//check if token is valid
const token = socket.encoded_token;
- const tokenExist = Database.tokensDB.chain.get('tokens').find({ token: token }).value();
+ const tokenExist = Database.tokensDB?.chain.get('tokens').find({ token: token }).value();
if (!tokenExist) {
log.debug(
@@ -68,11 +79,13 @@ export default class Socket {
socket.decoded_token.permissionLevel.includes('notifications:access') ||
socket.decoded_token.permissionLevel.includes('admin')
) {
- const notifications = await Database.interfaceDB.chain.get('notifications').cloneDeep().value();
- const systemNotifications = Database.notificationsDB.chain.get('notifications').cloneDeep().value();
- const size = notifications.length + systemNotifications.length;
+ const notifications = await Database.interfaceDB?.chain.get('notifications').cloneDeep().value();
+ const systemNotifications = Database.notificationsDB?.chain.get('notifications').cloneDeep().value();
- socket.emit('notification_size', size);
+ if (notifications && systemNotifications) {
+ const size = notifications.length + systemNotifications.length;
+ socket.emit('notification_size', size);
+ }
} else {
log.debug(`${socket.decoded_token.username} (${socket.conn.remoteAddress}) no access for notifications socket`);
}
@@ -125,6 +138,10 @@ export default class Socket {
Socket.io.emit('memory', Socket.#memoryUsageHistory);
});
+ socket.on('getDiskSpace', () => {
+ Socket.io.emit('diskSpace', Socket.#diskSpaceHistory);
+ });
+
socket.on('getMotionServerStatus', () => {
const ftpStatus = MotionController.ftpServer?.server?.listening;
const httpStatus = MotionController.httpServer?.listening;
@@ -252,8 +269,6 @@ export default class Socket {
}
});
- Socket.watchSystem();
-
return Socket.io;
}
@@ -276,17 +291,18 @@ export default class Socket {
}
static async watchSystem() {
- await Socket.handleUptime();
- await Socket.handleCpuLoad();
- await Socket.handleCpuTemperature();
- await Socket.handleMemoryUsage();
+ await Socket.#handleUptime();
+ await Socket.#handleCpuLoad();
+ await Socket.#handleCpuTemperature();
+ await Socket.#handleMemoryUsage();
+ await Socket.handleDiskUsage();
setTimeout(() => {
Socket.watchSystem();
}, 30000);
}
- static async handleUptime() {
+ static async #handleUptime() {
try {
const humaniseDuration = (seconds) => {
if (seconds < 50) {
@@ -315,7 +331,7 @@ export default class Socket {
Socket.io.emit('uptime', Socket.#uptime);
}
- static async handleCpuLoad() {
+ static async #handleCpuLoad() {
try {
const cpuLoad = await systeminformation.currentLoad();
let processLoad = await systeminformation.processLoad(process.title);
@@ -347,7 +363,7 @@ export default class Socket {
Socket.io.emit('cpuLoad', Socket.#cpuLoadHistory);
}
- static async handleCpuTemperature() {
+ static async #handleCpuTemperature() {
try {
const cpuTemperatureData = await systeminformation.cpuTemperature();
@@ -363,7 +379,7 @@ export default class Socket {
Socket.io.emit('cpuTemp', Socket.#cpuTempHistory);
}
- static async handleMemoryUsage() {
+ static async #handleMemoryUsage() {
try {
const mem = await systeminformation.mem();
const memoryFreePercent = mem ? ((mem.total - mem.available) / mem.total) * 100 : 50;
@@ -397,4 +413,42 @@ export default class Socket {
Socket.io.emit('memory', Socket.#memoryUsageHistory);
}
+
+ static async handleDiskUsage() {
+ try {
+ const settingsDatabase = await Database.interfaceDB.chain.get('settings').cloneDeep().value();
+ const recordingsPath = settingsDatabase?.recordings.path;
+
+ if (!recordingsPath) {
+ return;
+ }
+
+ const diskSpace = await checkDiskSpace(recordingsPath);
+ const recordingsSpace = await getFolderSize.loose(recordingsPath);
+
+ Socket.diskSpace = {
+ available: diskSpace.free / 1e9,
+ total: diskSpace.size / 1e9,
+ used: (diskSpace.size - diskSpace.free) / 1e9,
+ usedRecordings: recordingsSpace / 1e9,
+ recordingsPath: recordingsPath,
+ };
+
+ const usedPercent = Socket.diskSpace.used / Socket.diskSpace.total;
+ const usedRecordingsPercent = Socket.diskSpace.usedRecordings / Socket.diskSpace.total;
+
+ Socket.#diskSpaceHistory = Socket.#diskSpaceHistory.slice(-60);
+ Socket.#diskSpaceHistory.push({
+ time: new Date(),
+ value: usedPercent * 100,
+ value2: usedRecordingsPercent * 100,
+ available: Socket.diskSpace.available.toFixed(2),
+ total: Socket.diskSpace.total.toFixed(2),
+ });
+ } catch (error) {
+ log.error(error, 'Socket');
+ }
+
+ Socket.io.emit('diskSpace', Socket.#diskSpaceHistory);
+ }
}
diff --git a/src/controller/camera/camera.controller.js b/src/controller/camera/camera.controller.js
index 876f9fbb..1b42b16a 100644
--- a/src/controller/camera/camera.controller.js
+++ b/src/controller/camera/camera.controller.js
@@ -10,16 +10,14 @@ import VideoAnalysisService from './services/videoanalysis.service.js';
export default class CameraController {
static #controller;
- static #socket;
static cameras = new Map([]);
- constructor(controller, socket) {
+ constructor(controller) {
CameraController.#controller = controller;
- CameraController.#socket = socket;
for (const camera of ConfigService.ui.cameras) {
- CameraController.createController(camera, socket);
+ CameraController.createController(camera);
}
return CameraController.cameras;
@@ -27,22 +25,15 @@ export default class CameraController {
static createController(camera) {
const mediaService = new MediaService(camera);
- const prebufferService = new PrebufferService(camera, mediaService, CameraController.#socket);
+ const prebufferService = new PrebufferService(camera, mediaService);
const videoanalysisService = new VideoAnalysisService(
camera,
prebufferService,
mediaService,
- CameraController.#controller,
- CameraController.#socket
+ CameraController.#controller
);
const sessionService = new SessionService(camera);
- const streamService = new StreamService(
- camera,
- prebufferService,
- mediaService,
- sessionService,
- CameraController.#socket
- );
+ const streamService = new StreamService(camera, prebufferService, mediaService, sessionService);
const controller = {
options: camera,
@@ -63,9 +54,9 @@ export default class CameraController {
throw new Error(`Can not remove controller, controller for ${cameraName} not found!`);
}
- controller.prebuffer.stop(true);
- controller.videoanalysis.stop(true);
- controller.stream.stop();
+ controller.prebuffer.destroy();
+ controller.videoanalysis.destroy(true);
+ controller.stream.destroy();
controller.session.clearSession();
CameraController.cameras.delete(cameraName);
diff --git a/src/controller/camera/services/prebuffer.service.js b/src/controller/camera/services/prebuffer.service.js
index be46729f..4e046629 100644
--- a/src/controller/camera/services/prebuffer.service.js
+++ b/src/controller/camera/services/prebuffer.service.js
@@ -12,6 +12,8 @@ import Ping from '../../../common/ping.js';
import LoggerService from '../../../services/logger/logger.service.js';
import ConfigService from '../../../services/config/config.service.js';
+import Socket from '../../../api/socket.js';
+
const { log } = LoggerService;
const compatibleAudio = /(aac|mp3|mp2)/;
@@ -21,7 +23,6 @@ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export default class PrebufferService {
#camera;
- #socket;
#mediaService;
prebuffers = {
@@ -36,17 +37,17 @@ export default class PrebufferService {
prevIdr = 0;
prebufferSession = null;
killed = false;
+ destroyed = false;
watchdog = null;
parsers = {};
cameraState = true;
restartTimer = null;
- constructor(camera, mediaService, socket) {
+ constructor(camera, mediaService) {
//log.debug('Initializing camera prebuffering', camera.name);
this.#camera = camera;
- this.#socket = socket;
this.#mediaService = mediaService;
this.cameraName = camera.name;
@@ -124,6 +125,11 @@ export default class PrebufferService {
this.restartTimer = null;
}
+ destroy() {
+ this.destroyed = true;
+ this.resetPrebuffer();
+ }
+
stop(killed) {
if (this.prebufferSession) {
if (killed) {
@@ -145,6 +151,10 @@ export default class PrebufferService {
}
async restart() {
+ if (this.destroyed) {
+ return;
+ }
+
log.info('Restart prebuffer session..', this.cameraName);
this.stop(true);
@@ -577,7 +587,7 @@ export default class PrebufferService {
kill();
- this.#socket.emit('prebufferStatus', {
+ Socket.io.emit.emit('prebufferStatus', {
camera: this.cameraName,
status: 'inactive',
});
@@ -590,7 +600,7 @@ export default class PrebufferService {
await socketPromise;
clearTimeout(ffmpegTimeout);
- this.#socket.emit('prebufferStatus', {
+ Socket.io.emit.emit('prebufferStatus', {
camera: this.cameraName,
status: 'active',
});
diff --git a/src/controller/camera/services/stream.service.js b/src/controller/camera/services/stream.service.js
index ed10a81f..f782c342 100644
--- a/src/controller/camera/services/stream.service.js
+++ b/src/controller/camera/services/stream.service.js
@@ -9,23 +9,23 @@ import ConfigService from '../../../services/config/config.service.js';
import LoggerService from '../../../services/logger/logger.service.js';
import Database from '../../../api/database.js';
+import Socket from '../../../api/socket.js';
const { log } = LoggerService;
export default class StreamService {
- #socket;
#camera;
#prebufferService;
#sessionService;
#mediaService;
streamSession = null;
+ destroyed = false;
- constructor(camera, prebufferService, mediaService, sessionService, socket) {
+ constructor(camera, prebufferService, mediaService, sessionService) {
//log.debug('Initializing camera stream', camera.name);
this.#camera = camera;
- this.#socket = socket;
this.#sessionService = sessionService;
this.#mediaService = mediaService;
this.#prebufferService = prebufferService;
@@ -153,7 +153,7 @@ export default class StreamService {
let errors = [];
this.streamSession.stdout.on('data', (data) => {
- this.#socket.to(`stream/${this.cameraName}`).emit(this.cameraName, data);
+ Socket.io.emit.to(`stream/${this.cameraName}`).emit(this.cameraName, data);
});
this.streamSession.stderr.on('data', (data) => {
@@ -178,6 +178,11 @@ export default class StreamService {
}
}
+ destroy() {
+ this.destroyed = true;
+ this.stop();
+ }
+
stop() {
if (this.streamSession) {
log.debug('Stopping stream..', this.cameraName);
@@ -186,6 +191,10 @@ export default class StreamService {
}
restart() {
+ if (this.destroyed) {
+ return;
+ }
+
log.info('Restart stream session..', this.cameraName);
if (this.streamSession) {
diff --git a/src/controller/camera/services/videoanalysis.service.js b/src/controller/camera/services/videoanalysis.service.js
index 83fbff3a..697937a3 100644
--- a/src/controller/camera/services/videoanalysis.service.js
+++ b/src/controller/camera/services/videoanalysis.service.js
@@ -16,6 +16,8 @@ import ConfigService from '../../../services/config/config.service.js';
import MotionController from '../../motion/motion.controller.js';
+import Socket from '../../../api/socket.js';
+
const { log } = LoggerService;
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -45,12 +47,12 @@ const DEFAULT_ZONE = [
export default class VideoAnalysisService {
#camera;
#controller;
- #socket;
#prebufferService;
#mediaService;
videoanalysisSession = null;
killed = false;
+ destroyed = false;
cameraState = true;
restartTimer = null;
watchdog = null;
@@ -59,12 +61,11 @@ export default class VideoAnalysisService {
finishLaunching = false;
- constructor(camera, prebufferService, mediaService, controller, socket) {
+ constructor(camera, prebufferService, mediaService, controller) {
//log.debug('Initializing video analysis', camera.name);
this.#camera = camera;
this.#controller = controller;
- this.#socket = socket;
this.#prebufferService = prebufferService;
this.#mediaService = mediaService;
@@ -193,7 +194,16 @@ export default class VideoAnalysisService {
this.forceCloseTimeout = null;
}
+ destroy() {
+ this.destroyed = true;
+ this.resetVideoAnalysis();
+ }
+
stop(killed) {
+ if (this.destroyed) {
+ return;
+ }
+
if (this.videoanalysisSession) {
if (killed) {
this.killed = true;
@@ -305,7 +315,7 @@ export default class VideoAnalysisService {
p2p.on('pam', () => {
restartWatchdog();
- this.#socket.emit('videoanalysisStatus', {
+ Socket.io.emit.emit('videoanalysisStatus', {
camera: this.cameraName,
status: 'active',
});
@@ -384,7 +394,7 @@ export default class VideoAnalysisService {
log.debug('Videoanalysis process closed', this.cameraName);
- this.#socket.emit('videoanalysisStatus', {
+ Socket.io.emit.emit('videoanalysisStatus', {
camera: this.cameraName,
status: 'inactive',
});
diff --git a/src/controller/event/event.controller.js b/src/controller/event/event.controller.js
index ffe10ebe..2ebb5bfd 100644
--- a/src/controller/event/event.controller.js
+++ b/src/controller/event/event.controller.js
@@ -12,6 +12,8 @@ import Telegram from '../../common/telegram.js';
import LoggerService from '../../services/logger/logger.service.js';
+import Socket from '../../api/socket.js';
+
import * as CamerasModel from '../../api/components/cameras/cameras.model.js';
import * as NotificationsModel from '../../api/components/notifications/notifications.model.js';
import * as RecordingsModel from '../../api/components/recordings/recordings.model.js';
@@ -36,6 +38,7 @@ export default class EventController {
constructor(controller) {
EventController.#controller = controller;
+
EventController.#controller.on('uiMotion', (event) =>
EventController.handle(event.triggerType, event.cameraName, event.state)
);
@@ -132,6 +135,7 @@ export default class EventController {
const motionInfo = await EventController.#getMotionInfo(cameraName, trigger, recordingSettings);
+ //not used atm
let allowStream = true;
/*if (controller && !fileBuffer && recordingSettings.active && !Camera.prebuffering) {
@@ -144,74 +148,108 @@ export default class EventController {
}
if (allowStream) {
- if (!fileBuffer) {
- motionInfo.imgBuffer = await EventController.#handleSnapshot(Camera, recordingSettings.active);
- motionInfo.label = await EventController.#handleImageDetection(
- cameraName,
- awsSettings,
- CameraSettings.rekognition.labels,
- CameraSettings.rekognition.confidence,
- motionInfo.imgBuffer,
- CameraSettings.rekognition.active,
- recordingSettings.active
+ const diskSpace = Socket.diskSpace;
+ const allowRecording = Boolean(diskSpace.free >= 1);
+
+ if (!allowRecording) {
+ log.warn(
+ `The available disk space is less than 1 GB (${diskSpace.free.toFixed(
+ 2
+ )})! Please free up disk space to be able to create new recordings!`,
+ cameraName
);
}
- if (motionInfo.label || motionInfo.label === null) {
+ if (!fileBuffer) {
+ if (recordingSettings.active) {
+ if (allowRecording) {
+ motionInfo.imgBuffer = await EventController.#handleSnapshot(Camera);
+ motionInfo.label = await EventController.#handleImageDetection(
+ cameraName,
+ awsSettings,
+ CameraSettings.rekognition.labels,
+ CameraSettings.rekognition.confidence,
+ motionInfo.imgBuffer,
+ CameraSettings.rekognition.active
+ );
+ }
+ } else {
+ log.debug('Recording not enabled, skip Image Rekognition..', cameraName);
+ }
+ }
+
+ if (motionInfo.label) {
const { notification, notify } = await EventController.#handleNotification(motionInfo);
// 1)
- await EventController.#publishMqtt(cameraName, notification, notificationsSettings.active);
+ if (notificationsSettings.active) {
+ await EventController.#publishMqtt(cameraName, notification);
+ } else {
+ log.debug('Notifications not enabled, skip MQTT (notification)..', cameraName);
+ }
// 2)
- await EventController.#sendWebhook(
- cameraName,
- notification,
- webhookSettings,
- notificationsSettings.active
- );
+ if (notificationsSettings.active) {
+ await EventController.#sendWebhook(cameraName, notification, webhookSettings);
+ } else {
+ log.debug('Notifications not enabled, skip Webhook..', cameraName);
+ }
// 3)
- if (notificationsSettings.active && !recordingSettings.active) {
- log.notify(notify);
-
- await EventController.#sendWebpush(
- cameraName,
- notification,
- webpushSettings,
- notificationsSettings.active
- );
+ if (!recordingSettings.active || !allowRecording) {
+ if (notificationsSettings.active) {
+ log.notify(notify);
+
+ await EventController.#sendWebpush(cameraName, notification, webpushSettings);
+ } else {
+ log.debug('Notifications not enabled, skip Webpush..', cameraName);
+ }
}
// 4)
- await EventController.#sendAlexa(cameraName, alexaSettings, notificationsSettings.active);
+ if (notificationsSettings.active) {
+ await EventController.#sendAlexa(cameraName, alexaSettings);
+ } else {
+ log.debug('Notifications not enabled, skip Alexa..', cameraName);
+ }
// 5)
- if (telegramSettings.type === 'Text') {
- await EventController.#sendTelegram(
- cameraName,
- notification,
- recordingSettings,
- telegramSettings,
- false,
- fileBuffer,
- notificationsSettings.active
- );
+ if (telegramSettings.type === 'Text' || !allowRecording) {
+ if (notificationsSettings.active) {
+ await EventController.#sendTelegram(
+ cameraName,
+ notification,
+ recordingSettings,
+ telegramSettings,
+ false,
+ fileBuffer,
+ allowRecording
+ );
+ } else {
+ log.debug('Notifications not enabled, skip Telegram..', cameraName);
+ }
}
// 6)
- await EventController.#handleRecording(cameraName, motionInfo, fileBuffer, recordingSettings.active);
+ if (recordingSettings.active) {
+ if (allowRecording) {
+ await EventController.#handleRecording(cameraName, motionInfo, fileBuffer);
+ }
+ } else {
+ log.debug('Recording not enabled, skip recording..', cameraName);
+ }
// 7)
- if (notificationsSettings.active && recordingSettings.active) {
- log.notify(notify);
-
- await EventController.#sendWebpush(
- cameraName,
- notification,
- webpushSettings,
- notificationsSettings.active
- );
+ if (recordingSettings.active) {
+ if (notificationsSettings.active) {
+ if (allowRecording) {
+ log.notify(notify);
+
+ await EventController.#sendWebpush(cameraName, notification, webpushSettings);
+ }
+ } else {
+ log.debug('Notifications not enabled, skip Webpush..', cameraName);
+ }
}
// 8)
@@ -222,21 +260,30 @@ export default class EventController {
telegramSettings.type === 'Video') &&
recordingSettings.active
) {
- await EventController.#sendTelegram(
- cameraName,
- notification,
- recordingSettings,
- telegramSettings,
- false,
- fileBuffer,
- notificationsSettings.active
- );
+ if (notificationsSettings.active) {
+ if (allowRecording) {
+ await EventController.#sendTelegram(
+ cameraName,
+ notification,
+ recordingSettings,
+ telegramSettings,
+ false,
+ fileBuffer
+ );
+ }
+ } else {
+ log.debug('Notifications not enabled, skip Telegram..', cameraName);
+ }
}
log.debug(
- `${recordingSettings.active ? 'Recording saved.' : 'Recording skipped.'} ${
- notificationsSettings.active ? 'Notification send.' : 'Notification skipped.'
- }`,
+ `${
+ recordingSettings.active && allowRecording
+ ? 'Recording saved.'
+ : !allowRecording
+ ? 'Recording not allowed.'
+ : 'Recording skipped.'
+ } ${notificationsSettings.active ? 'Notification send.' : 'Notification skipped.'}`,
cameraName
);
} else {
@@ -273,7 +320,7 @@ export default class EventController {
}
}
- static async #getMotionInfo(cameraName, trigger, recordingSettings) {
+ static async #getMotionInfo(cameraName, trigger, recordingSettings, allowRecording) {
const id = await nanoid();
const timestamp = Math.round(Date.now() / 1000);
@@ -282,7 +329,7 @@ export default class EventController {
camera: cameraName,
label: null,
path: recordingSettings.path,
- storing: recordingSettings.active,
+ storing: Boolean(recordingSettings.active && allowRecording),
type: recordingSettings.type,
timer: recordingSettings.timer,
timestamp: timestamp,
@@ -290,25 +337,20 @@ export default class EventController {
};
}
- static async #handleImageDetection(cameraName, aws, labels, confidence, imgBuffer, camRekognition, recordingActive) {
- if (!recordingActive) {
- log.debug('Recording not enabled, skip Image Rekognition..', cameraName);
- return null;
- }
-
+ static async #handleImageDetection(cameraName, aws, labels, confidence, imgBuffer, camRekognition) {
if (!aws.active || !camRekognition) {
log.debug('Image Rekognition not enabled, skip Image Rekognition..', cameraName);
- return null;
+ return 'no label';
}
if (aws.contingent_total <= 0) {
log.debug('Contingent total is not greater 0, skip Image Rekognition..', cameraName);
- return null;
+ return 'no label';
}
if (aws.contingent_left <= 0) {
log.debug('No contingent left, skip Image Rekognition..', cameraName);
- return null;
+ return 'no label';
}
let detected = [];
@@ -375,28 +417,15 @@ export default class EventController {
return await NotificationsModel.createNotification(motionInfo);
}
- static async #handleRecording(cameraName, motionInfo, fileBuffer, recordingActive) {
- if (!recordingActive) {
- log.debug('Recording not enabled, skip recording..', cameraName);
- return;
- }
-
+ static async #handleRecording(cameraName, motionInfo, fileBuffer) {
return await RecordingsModel.createRecording(motionInfo, fileBuffer);
}
- static async #handleSnapshot(camera, recordingActive) {
- if (!recordingActive) {
- return;
- }
-
+ static async #handleSnapshot(camera) {
return await CamerasModel.requestSnapshot(camera);
}
- static async #sendAlexa(cameraName, alexaSettings, notificationActive) {
- if (!notificationActive) {
- return log.debug('Notifications not enabled, skip Alexa..', cameraName);
- }
-
+ static async #sendAlexa(cameraName, alexaSettings) {
if (!alexaSettings.active || !alexaSettings.enabled) {
return log.debug('Alexa not enabled, skip Alexa..', cameraName);
}
@@ -458,12 +487,8 @@ export default class EventController {
telegramSettings,
imgBuffer,
customBuffer,
- notificationActive
+ allowRecording
) {
- if (!notificationActive) {
- return log.debug('Notifications not enabled, skip Telegram..', cameraName);
- }
-
if (!telegramSettings.active || telegramSettings.type === 'Disabled') {
return log.debug('Telegram not enabled, skip Telegram..', cameraName);
}
@@ -500,24 +525,30 @@ export default class EventController {
case 'Snapshot':
case 'Text + Snapshot': {
//Snapshot
- if (recordingSettings.active || imgBuffer || customBuffer) {
- const content = {
- fileName: notification.fileName,
- };
+ if (recordingSettings.active || imgBuffer || customBuffer || allowRecording !== undefined) {
+ if (telegramSettings.type === 'Snapshot' && allowRecording !== undefined) {
+ return;
+ }
+
+ const content = {};
if (telegramSettings.type === 'Text + Snapshot' && telegramSettings.message) {
content.message = telegramSettings.message;
}
- if (imgBuffer) {
- content.img = imgBuffer;
- } else {
- const fileName =
- customBuffer || recordingSettings.type === 'Video'
- ? `${notification.name}@2.jpeg`
- : notification.fileName;
+ if (allowRecording === undefined) {
+ content.fileName = notification.fileName;
+
+ if (imgBuffer) {
+ content.img = imgBuffer;
+ } else {
+ const fileName =
+ customBuffer || recordingSettings.type === 'Video'
+ ? `${notification.name}@2.jpeg`
+ : notification.fileName;
- content.img = `${recordingSettings.path}/${fileName}`;
+ content.img = `${recordingSettings.path}/${fileName}`;
+ }
}
await Telegram.send(telegramSettings.chatID, content);
@@ -532,16 +563,24 @@ export default class EventController {
}
case 'Video':
case 'Text + Video': {
- if ((recordingSettings.active && recordingSettings.type === 'Video') || customBuffer) {
- const content = {
- fileName: notification.fileName,
- };
+ if (
+ (recordingSettings.active && recordingSettings.type === 'Video') ||
+ customBuffer ||
+ allowRecording !== undefined
+ ) {
+ if (telegramSettings.type === 'Video' && allowRecording !== undefined) {
+ return;
+ }
+
+ const content = {};
if (telegramSettings.type === 'Text + Video' && telegramSettings.message) {
content.message = telegramSettings.message;
}
- content.video = customBuffer ? customBuffer : `${recordingSettings.path}/${notification.fileName}`;
+ if (allowRecording === undefined) {
+ content.video = customBuffer ? customBuffer : `${recordingSettings.path}/${notification.fileName}`;
+ }
await Telegram.send(telegramSettings.chatID, content);
}
@@ -558,11 +597,7 @@ export default class EventController {
}
}
- static async #sendWebhook(cameraName, notification, webhookSettings, notificationActive) {
- if (!notificationActive) {
- return log.debug('Notifications not enabled, skip Webhook..', cameraName);
- }
-
+ static async #sendWebhook(cameraName, notification, webhookSettings) {
if (!webhookSettings.active) {
return log.debug('Webhook not enabled, skip Webhook..', cameraName);
}
@@ -594,11 +629,7 @@ export default class EventController {
}
}
- static async #publishMqtt(cameraName, notification, notificationActive) {
- if (!notificationActive) {
- return log.debug('Notifications not enabled, skip MQTT (notification)..', cameraName);
- }
-
+ static async #publishMqtt(cameraName, notification) {
try {
const mqttClient = EventController.#controller.motionController?.mqttClient;
@@ -613,11 +644,7 @@ export default class EventController {
}
}
- static async #sendWebpush(cameraName, notification, webpushSettings, notificationActive) {
- if (!notificationActive) {
- return log.debug('Notifications not enabled, skip Webpush..', cameraName);
- }
-
+ static async #sendWebpush(cameraName, notification, webpushSettings) {
if (!webpushSettings.publicKey || !webpushSettings.privateKey || !webpushSettings.subscription) {
return log.debug('Webpush grant expired, skip Webpush..');
}
diff --git a/src/controller/motion/motion.controller.js b/src/controller/motion/motion.controller.js
index 4b8d1aa8..0d620281 100644
--- a/src/controller/motion/motion.controller.js
+++ b/src/controller/motion/motion.controller.js
@@ -18,6 +18,7 @@ import ConfigService from '../../services/config/config.service.js';
import LoggerService from '../../services/logger/logger.service.js';
import Database from '../../api/database.js';
+import Socket from '../../api/socket.js';
const { log } = LoggerService;
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,7 +39,6 @@ const toDotNot = (input, parentKey) =>
export default class MotionController {
static #controller;
- static #socket;
static #motionTimers = new Map();
static httpServer = null;
@@ -46,9 +46,8 @@ export default class MotionController {
static smtpServer = null;
static ftpServer = null;
- constructor(controller, socket) {
+ constructor(controller) {
MotionController.#controller = controller;
- MotionController.#socket = socket;
if (ConfigService.ui.http) {
MotionController.startHttpServer();
@@ -109,7 +108,7 @@ export default class MotionController {
log.debug(`HTTP server for motion detection is listening on ${bind}`);
- MotionController.#socket.emit('httpStatus', {
+ Socket.io.emit('httpStatus', {
status: 'online',
});
});
@@ -136,7 +135,7 @@ export default class MotionController {
log.error(error_, 'HTTP Server', 'motion');
- MotionController.#socket.emit('httpStatus', {
+ Socket.io.emit('httpStatus', {
status: 'offline',
});
});
@@ -177,7 +176,7 @@ export default class MotionController {
MotionController.httpServer.on('close', () => {
log.debug('HTTP Server closed');
- MotionController.#socket.emit('httpStatus', {
+ Socket.io.emit('httpStatus', {
status: 'offline',
});
});
@@ -213,7 +212,7 @@ export default class MotionController {
MotionController.mqttClient.subscribe(topic + '/#');
}
- MotionController.#socket.emit('mqttStatus', {
+ Socket.io.emit('mqttStatus', {
status: 'online',
});
});
@@ -342,7 +341,7 @@ export default class MotionController {
MotionController.mqttClient.on('end', () => {
log.debug('MQTT client disconnected');
- MotionController.#socket.emit('mqttStatus', {
+ Socket.io.emit('mqttStatus', {
status: 'offline',
});
});
@@ -449,7 +448,7 @@ export default class MotionController {
MotionController.smtpServer.server.on('listening', () => {
log.debug(`SMTP server for motion detection is listening on port ${ConfigService.ui.smtp.port}`);
- MotionController.#socket.emit('smtpStatus', {
+ Socket.io.emit('smtpStatus', {
status: 'online',
});
});
@@ -457,7 +456,7 @@ export default class MotionController {
MotionController.smtpServer.server.on('close', () => {
log.debug('SMTP Server closed');
- MotionController.#socket.emit('smtpStatus', {
+ Socket.io.emit('smtpStatus', {
status: 'offline',
});
});
@@ -618,7 +617,7 @@ export default class MotionController {
MotionController.ftpServer.server.on('listening', () => {
log.debug(`FTP server for motion detection is listening on port ${ConfigService.ui.ftp.port}`);
- MotionController.#socket.emit('ftpStatus', {
+ Socket.io.emit('ftpStatus', {
status: 'online',
});
});
@@ -629,7 +628,7 @@ export default class MotionController {
log.debug('FTP Server closed');
- MotionController.#socket.emit('ftpStatus', {
+ Socket.io.emit('ftpStatus', {
status: 'offline',
});
}
diff --git a/src/main.js b/src/main.js
index d477ecd7..1f5c430f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -59,11 +59,11 @@ export default class Interface extends EventEmitter {
// configure motion controller
this.log.debug('Configuring motion controller...');
- this.motionController = new (await import('./controller/motion/motion.controller.js')).default(this, this.#socket);
+ this.motionController = new (await import('./controller/motion/motion.controller.js')).default(this);
// configure camera controller
this.log.debug('Configuring camera controller...');
- this.cameraController = new (await import('./controller/camera/camera.controller.js')).default(this, this.#socket);
+ this.cameraController = new (await import('./controller/camera/camera.controller.js')).default(this);
await Promise.all(
[...this.cameraController.entries()].map(async (controller) => {
diff --git a/src/services/logger/logger.service.js b/src/services/logger/logger.service.js
index eb19df77..8102be26 100644
--- a/src/services/logger/logger.service.js
+++ b/src/services/logger/logger.service.js
@@ -165,12 +165,6 @@ export default class LoggerService {
static formatMessage(message, name, level) {
let formatted = '';
- if (level === LogLevel.WARN) {
- formatted += `${chalk.bgYellowBright.black.bold(' WARNING ')} `;
- } else if (level === LogLevel.ERROR) {
- formatted += `${chalk.bgRedBright.white.bold(' ERROR ')} `;
- }
-
if (name) {
formatted += `${name}: `;
}
@@ -185,6 +179,12 @@ export default class LoggerService {
formatted += message;
}
+ if (level === LogLevel.WARN) {
+ formatted = `${chalk.bgYellowBright.black.bold(' WARNING ')} ` + formatted;
+ } else if (level === LogLevel.ERROR) {
+ formatted = `${chalk.bgRedBright.white.bold(' ERROR ')} ` + formatted;
+ }
+
switch (level) {
case LogLevel.WARN:
formatted = chalk.yellow(formatted);
diff --git a/ui/src/api/system.api.js b/ui/src/api/system.api.js
index 18be7690..a418f50d 100644
--- a/ui/src/api/system.api.js
+++ b/ui/src/api/system.api.js
@@ -2,6 +2,7 @@ import api from './index';
const resource = '/system';
const changelog_resource = 'changelog';
+const disk_resource = 'disk';
const db_resource = 'db';
const db_download_resource = 'db/download';
const ftp_restart_resource = 'ftp/restart';
@@ -30,6 +31,8 @@ export const downloadLog = async () => await api.get(`${resource}/${log_download
export const getChangelog = async (parameters) =>
await api.get(`${resource}/${changelog_resource}${parameters ? parameters : ''}`);
+export const getDiskLoad = async () => await api.get(`${resource}/${disk_resource}`);
+
export const getDb = async () => await api.get(`${resource}/${db_resource}`);
export const getFtpServerStatus = async () => await api.get(`${resource}/${ftp_status_resource}`);
diff --git a/ui/src/components/add-camera.vue b/ui/src/components/add-camera.vue
index 5b0be25b..165d84c6 100644
--- a/ui/src/components/add-camera.vue
+++ b/ui/src/components/add-camera.vue
@@ -22,10 +22,14 @@ v-dialog(v-model="dialog" width="600" scrollable @click:outside="closeDialog")
label.form-input-label Video Source
span.tw-text-red-500 *
- v-text-field(v-model="cam.videoConfig.source" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" :rules="rules.string" required solo)
+ v-text-field(v-model="cam.videoConfig.source" :hint="$t('source_info')" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" :rules="rules.string" required solo)
template(v-slot:prepend-inner)
v-icon.text-muted {{ icons['mdiAlphabetical'] }}
-
+ template(v-slot:message="{ key, message}")
+ .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
+ v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
+ .input-info.tw-italic {{ message }}
+
label.form-input-label Video Substream Source
v-text-field(v-model="cam.videoConfig.subSource" :hint="$t('sub_source_info')" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
template(v-slot:prepend-inner)
@@ -44,60 +48,6 @@ v-dialog(v-model="dialog" width="600" scrollable @click:outside="closeDialog")
v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
.input-info.tw-italic {{ message }}
- label.form-input-label Motion Timeout
- v-text-field(v-model.number="cam.motionTimeout" :hint="$t('motion_timeout_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
- label.form-input-label Max Streams
- v-text-field(v-model.number="cam.videoConfig.maxStreams" :hint="$t('max_streams_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
- label.form-input-label Video Width
- v-text-field(v-model.number="cam.videoConfig.maxWidth" :hint="$t('width_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
- label.form-input-label Video Height
- v-text-field(v-model.number="cam.videoConfig.maxHeight" :hint="$t('height_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
- label.form-input-label FPS
- v-text-field(v-model.number="cam.videoConfig.maxFPS" :hint="$t('fps_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
- label.form-input-label Bitrate
- v-text-field(v-model.number="cam.videoConfig.maxBitrate" :hint="$t('bitrate_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
label.form-input-label RTSP Transport
v-text-field(v-model="cam.videoConfig.rtspTransport" :hint="$t('rtsp_transport_info')" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
template(v-slot:prepend-inner)
@@ -125,15 +75,6 @@ v-dialog(v-model="dialog" width="600" scrollable @click:outside="closeDialog")
v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
.input-info.tw-italic {{ message }}
- label.form-input-label Stream Timeout
- v-text-field(v-model.number="cam.videoConfig.stimeout" :hint="$t('timeout_info')" persistent-hint type="number" prepend-inner-icon="mdi-numeric" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiNumeric'] }}
- template(v-slot:message="{ key, message}")
- .tw-flex.tw-flex-row.tw-items-center.tw-break-normal
- v-icon.text-muted.tw-mr-1(small) {{ icons['mdiInformationOutline'] }}
- .input-info.tw-italic {{ message }}
-
v-divider.tw-my-6
div
@@ -184,31 +125,6 @@ v-dialog(v-model="dialog" width="600" scrollable @click:outside="closeDialog")
.input-info.tw-italic {{ $t('videoanalysis_info') }}
v-switch(color="var(--cui-primary)" v-model="cam.videoanalysis.active")
- v-divider.tw-my-6
-
- div
- h2.tw-mb-5 {{ $t('mqtt') }}
-
- label.form-input-label Motion Topic
- v-text-field(v-model="cam.mqtt.motionTopic" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiAlphabetical'] }}
-
- label.form-input-label Motion Message
- v-text-field(v-model="cam.mqtt.motionMessage" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiAlphabetical'] }}
-
- label.form-input-label Motion Reset Topic
- v-text-field(v-model="cam.mqtt.motionResetTopic" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiAlphabetical'] }}
-
- label.form-input-label Motion Reset Message
- v-text-field(v-model="cam.mqtt.motionResetMessage" persistent-hint prepend-inner-icon="mdi-alphabetical" background-color="var(--cui-bg-card)" color="var(--cui-text-default)" solo)
- template(v-slot:prepend-inner)
- v-icon.text-muted {{ icons['mdiAlphabetical'] }}
-
v-divider.tw-mt-3.tw-mb-6(v-if="moduleName === 'homebridge-camera-ui' || env === 'development'")
div(v-if="moduleName === 'homebridge-camera-ui' || env === 'development'")
diff --git a/ui/src/components/sidebar-widgets.vue b/ui/src/components/sidebar-widgets.vue
index 62ff8de6..db5fd5d2 100644
--- a/ui/src/components/sidebar-widgets.vue
+++ b/ui/src/components/sidebar-widgets.vue
@@ -18,6 +18,7 @@ import { bus } from '@/main';
import { CameraPlaceholder, CameraWidget } from '@/widgets/camera';
import { ChartPlaceholder, ChartWidget } from '@/widgets/chart';
import { ConsolePlaceholder, ConsoleWidget } from '@/widgets/console';
+import { DiskPlaceholder, DiskWidget } from '@/widgets/disk';
import { NotificationsPlaceholder, NotificationsWidget } from '@/widgets/notifications';
import { RssPlaceholder, RssWidget } from '@/widgets/rss';
import { ShortcutsPlaceholder, ShortcutsWidget } from '@/widgets/shortcuts';
@@ -121,6 +122,30 @@ export default {
},
items: [],
},
+ {
+ type: 'DiskWidget',
+ name: this.$t('disk_space'),
+ placeholderComponent: DiskPlaceholder.default,
+ widgetComponent: DiskWidget.default,
+ defaultWidgetData: {
+ w: 3,
+ h: 2,
+ minW: 3,
+ maxW: 4,
+ minH: 2,
+ maxH: 2,
+ disableDrag: false,
+ disableResize: false,
+ },
+ items: [
+ {
+ id: 'freeSpace',
+ },
+ {
+ id: 'usedByRecordings',
+ },
+ ],
+ },
{
type: 'NotificationsWidget',
name: this.$t('notifications'),
diff --git a/ui/src/components/utilization-charts.vue b/ui/src/components/utilization-charts.vue
index 690d95a4..c11c1ac2 100644
--- a/ui/src/components/utilization-charts.vue
+++ b/ui/src/components/utilization-charts.vue
@@ -125,7 +125,7 @@ export default {
gradient2.addColorStop(1, 'rgba(56, 56, 56, 0)');
this.datacollection.datasets.push({
- label: 'camera.ui',
+ label: this.dataset.label2,
borderColor: '#383838',
pointBackgroundColor: '#383838',
borderWidth: 1,
diff --git a/ui/src/i18n/locale/de.json b/ui/src/i18n/locale/de.json
index 1ddb024e..02c21361 100644
--- a/ui/src/i18n/locale/de.json
+++ b/ui/src/i18n/locale/de.json
@@ -119,6 +119,8 @@
"december": "Dezember",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "Deaktiviert",
+ "disk_load": "Festplatte Auslastung",
+ "disk_space": "Festplattenkapazität",
"domain": "Domain",
"doorbell": "Türklingel",
"doorbellSensor_info": "Exposes the doorbell device for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config.",
@@ -164,6 +166,7 @@
"fps": "FPS",
"fps_info": "The fps used for video stream.",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Verbleibender Speicherplatz",
"friday": "Freitag",
"from": "Von",
"ftp": "FTP",
diff --git a/ui/src/i18n/locale/en.json b/ui/src/i18n/locale/en.json
index a228c85b..43b9726c 100644
--- a/ui/src/i18n/locale/en.json
+++ b/ui/src/i18n/locale/en.json
@@ -118,6 +118,8 @@
"december": "December",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "Disabled",
+ "disk_load": "Disk Load",
+ "disk_space": "Disk Space",
"domain": "Domain",
"doorbell": "Doorbell",
"doorbellSensor_info": "Exposes the doorbell device for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config.",
@@ -163,6 +165,7 @@
"fps": "FPS",
"fps_info": "The fps used for video stream.",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Free Disk Space",
"friday": "Friday",
"from": "From",
"ftp": "FTP",
diff --git a/ui/src/i18n/locale/es.json b/ui/src/i18n/locale/es.json
index 534fbcc6..eae780f4 100644
--- a/ui/src/i18n/locale/es.json
+++ b/ui/src/i18n/locale/es.json
@@ -118,6 +118,8 @@
"december": "Diciembre",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "Desactivado",
+ "disk_load": "Disk Load",
+ "disk_space": "Disk Space",
"domain": "Dominio",
"doorbell": "Timbre",
"doorbellSensor_info": "Exposes the doorbell device for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config.",
@@ -163,6 +165,7 @@
"fps": "FPS",
"fps_info": "The fps used for video stream.",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Free Disk Space",
"friday": "Viernes",
"from": "Desde",
"ftp": "FTP",
diff --git a/ui/src/i18n/locale/fr.json b/ui/src/i18n/locale/fr.json
index b11e2454..0b9c68ec 100644
--- a/ui/src/i18n/locale/fr.json
+++ b/ui/src/i18n/locale/fr.json
@@ -118,6 +118,8 @@
"december": "Décembre",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "Désactivé",
+ "disk_load": "Disk Load",
+ "disk_space": "Disk Space",
"domain": "Domaine",
"doorbell": "Sonette",
"doorbellSensor_info": "Expose le périphérique de sonnette pour cette caméra. Cela peut être déclenché avec les interrupteurs factices, les messages MQTT ou via HTTP, selon les fonctionnalités activées dans la configuration.",
@@ -163,6 +165,7 @@
"fps": "FPS",
"fps_info": "Les fps (images par secondes) utilisé pour le flux vidéo.",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Free Disk Space",
"friday": "Vendredi",
"from": "De",
"ftp": "FTP",
diff --git a/ui/src/i18n/locale/nl.json b/ui/src/i18n/locale/nl.json
index 8d794ee3..f24c2f3d 100644
--- a/ui/src/i18n/locale/nl.json
+++ b/ui/src/i18n/locale/nl.json
@@ -118,6 +118,8 @@
"december": "December",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "Uitgeschakeld",
+ "disk_load": "Disk Load",
+ "disk_space": "Disk Space",
"domain": "Domain",
"doorbell": "Deurbel",
"doorbellSensor_info": "Exposes the doorbell device for this camera. This can be triggered with the dummy switches, MQTT messages, or via HTTP, depending on what features are enabled in the config.",
@@ -163,6 +165,7 @@
"fps": "FPS",
"fps_info": "The fps used for video stream.",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Free Disk Space",
"friday": "Vrijdag",
"from": "Van",
"ftp": "FTP",
diff --git a/ui/src/i18n/locale/th.json b/ui/src/i18n/locale/th.json
index 5a93b912..5c386937 100644
--- a/ui/src/i18n/locale/th.json
+++ b/ui/src/i18n/locale/th.json
@@ -118,6 +118,8 @@
"december": "ธันวาคม",
"disable_info": "Disables the camera and removes it from HomeKit.",
"disabled": "ปิดการใช้งาน",
+ "disk_load": "Disk Load",
+ "disk_space": "Disk Space",
"domain": "โดเมน",
"doorbell": "กริ่งประตู",
"doorbellSensor_info": "Eเผยให้เห็นอุปกรณ์กริ่งประตูสำหรับกล้องนี้ สิ่งนี้สามารถทริกเกอร์ได้ด้วยสวิตช์จำลอง ข้อความ MQTT หรือผ่าน HTTP ขึ้นอยู่กับคุณสมบัติที่เปิดใช้งานในการกำหนดค่า",
@@ -165,6 +167,7 @@
"fps": "FPS",
"fps_info": "fps ที่ใช้สำหรับการสตรีมวิดีโอ",
"fps_info_hksv": "The maximum frame rate used for HKSV. If not set, will use any size HomeKit requests (-r).",
+ "free_disk_space": "Free Disk Space",
"friday": "วันศุกร์",
"from": "จาก",
"ftp": "FTP",
diff --git a/ui/src/mixins/socket.js b/ui/src/mixins/socket.js
index 686b1d47..1a02fbcf 100644
--- a/ui/src/mixins/socket.js
+++ b/ui/src/mixins/socket.js
@@ -58,6 +58,8 @@ export default {
console.log('Disconnected from socket');
//this.connected = false;
},
+ // eslint-disable-next-line no-unused-vars
+ diskSpace(data) {},
async unauthenticated() {
console.log('Disconnected from socket, unauthenticated!');
this.connected = false;
diff --git a/ui/src/views/Plugins/Plugins.vue b/ui/src/views/Plugins/Plugins.vue
index f381b257..5254e483 100644
--- a/ui/src/views/Plugins/Plugins.vue
+++ b/ui/src/views/Plugins/Plugins.vue
@@ -33,9 +33,20 @@
v-btn(fab x-small color="var(--cui-primary)")
v-icon(size="20" color="white") {{ icons["mdiOpenInNew"] }}
+ LightBox(
+ ref="lightboxBanner"
+ :media="notImages"
+ :showLightBox="false"
+ :showThumbs="false"
+ showCaption
+ disableScroll
+ )
+
+
+
diff --git a/ui/src/views/Utilization/Utilization.vue b/ui/src/views/Utilization/Utilization.vue
index 76cfbaa6..02ab5c95 100644
--- a/ui/src/views/Utilization/Utilization.vue
+++ b/ui/src/views/Utilization/Utilization.vue
@@ -33,6 +33,16 @@
.chart-badge.tw-flex.tw-justify-center.tw-items-center.tw-text-white(style="top: 130px; background: rgb(56, 56, 56)") {{ memoryData.data.length ? `${Math.round(memoryData.data[memoryData.data.length-1].value2)}%` : '0%' }}
Chart.tw-mt-5(:dataset="memoryData" :options="areaMemoryOptions")
+ .tw-mt-10.tw-mb-10.tw-relative
+ h3 {{ $t('disk_load') }}
+ .chart-badge-loading.tw-flex.tw-justify-center.tw-items-center(v-if="!diskSpaceData.data.length")
+ v-progress-circular(indeterminate color="var(--cui-primary)")
+ .chart-badge.tw-flex.tw-flex-col.tw-justify-center.tw-items-center
+ .tw-text-white(style="font-size: 0.8rem !important; font-weight: 100;") {{ diskSpaceData.data.length ? diskSpaceData.data[diskSpaceData.data.length-1].available : '-' }} GB /
+ .tw-text-white(style="font-size: 0.9rem !important; font-weight: bolder;") {{ diskSpaceData.data.length ? diskSpaceData.data[diskSpaceData.data.length-1].total : '-' }} GB
+ .chart-badge.tw-flex.tw-justify-center.tw-items-center.tw-text-white(style="top: 130px; background: rgb(56, 56, 56)") {{ diskSpaceData.data.length ? `${Math.round(diskSpaceData.data[diskSpaceData.data.length-1].value2)} GB` : '? GB' }}
+ Chart.tw-mt-5(:dataset="diskSpaceData" :options="areaDiskSpaceOptions")
+
LightBox(
ref="lightboxBanner"
:media="notImages"
@@ -72,15 +82,22 @@ export default {
loading: true,
cpuData: {
- label: this.$t('load'),
+ label: this.$t('system'),
+ label2: 'camera.ui',
data: [],
},
memoryData: {
- label: this.$t('memory'),
+ label: this.$t('system'),
+ label2: 'camera.ui',
data: [],
},
tempData: {
- label: this.$t('temperature'),
+ label: this.$t('system'),
+ data: [],
+ },
+ diskSpaceData: {
+ label: this.$t('system'),
+ label2: this.$t('recordings'),
data: [],
},
@@ -291,6 +308,75 @@ export default {
],
},
},
+ areaDiskSpaceOptions: {
+ responsive: true,
+ maintainAspectRatio: false,
+ elements: {
+ point: {
+ radius: 0,
+ hitRadius: 20,
+ hoverRadius: 10,
+ },
+ },
+ tooltips: {
+ enabled: true,
+ mode: 'single',
+ callbacks: {
+ title: (tooltipItems) => {
+ let time = new Date(tooltipItems[0].xLabel);
+ time.setTime(time.getTime() - new Date().getTimezoneOffset() * 60 * 1000);
+ time = time.toISOString().split('T');
+ return `${time[0]} - ${time[1].split('.')[0]}`;
+ },
+ label: (tooltipItems) => {
+ return ` ${tooltipItems.yLabel.toFixed(2)}%`;
+ },
+ },
+ },
+ scales: {
+ xAxes: [
+ {
+ display: true,
+ gridLines: {
+ display: true,
+ color: 'rgba(92,92,92, 0.3)',
+ },
+ scaleLabel: {
+ display: false,
+ //labelString: 'Month',
+ },
+ type: 'time',
+ time: {
+ unit: 'minutes',
+ displayFormats: { minutes: 'HH:mm' },
+ unitStepSize: 3,
+ },
+ },
+ ],
+ yAxes: [
+ {
+ display: true,
+ gridLines: {
+ display: true,
+ color: 'rgba(92,92,92, 0.3)',
+ },
+ scaleLabel: {
+ display: false,
+ //labelString: 'Value',
+ },
+ ticks: {
+ min: 0,
+ max: 100,
+ stepSize: 10,
+ callback: function (value) {
+ return value + '%';
+ },
+ },
+ type: 'linear',
+ },
+ ],
+ },
+ },
};
},
@@ -298,10 +384,12 @@ export default {
this.$socket.client.on('cpuLoad', this.cpuLoad);
this.$socket.client.on('cpuTemp', this.cpuTemp);
this.$socket.client.on('memory', this.memory);
+ this.$socket.client.on('diskSpace', this.diskSpace);
this.$socket.client.emit('getCpuLoad');
this.$socket.client.emit('getCpuTemp');
this.$socket.client.emit('getMemory');
+ this.$socket.client.emit('getDiskSpace');
},
mounted() {
@@ -312,6 +400,7 @@ export default {
this.$socket.client.off('cpuLoad', this.cpuLoad);
this.$socket.client.off('cpuTemp', this.cpuTemp);
this.$socket.client.off('memory', this.memory);
+ this.$socket.client.off('diskSpace', this.diskSpace);
},
methods: {
@@ -324,6 +413,9 @@ export default {
memory(data) {
this.memoryData.data = data;
},
+ diskSpace(data) {
+ this.diskSpaceData.data = data;
+ },
},
};
diff --git a/ui/src/widgets/disk/index.js b/ui/src/widgets/disk/index.js
new file mode 100644
index 00000000..7f7ad8d2
--- /dev/null
+++ b/ui/src/widgets/disk/index.js
@@ -0,0 +1,2 @@
+export * as DiskPlaceholder from './placeholder.vue';
+export * as DiskWidget from './widget.vue';
diff --git a/ui/src/widgets/disk/placeholder.vue b/ui/src/widgets/disk/placeholder.vue
new file mode 100644
index 00000000..7bbf9888
--- /dev/null
+++ b/ui/src/widgets/disk/placeholder.vue
@@ -0,0 +1,91 @@
+
+div
+ .grid-stack-item(v-for="(placeholder, i) in placeholders" :key="placeholder.id" :gs-id="placeholder.id" :gs-type="placeholder.type" :gs-w="placeholder.w" :gs-min-w="placeholder.minW" :gs-max-w="placeholder.maxW" :gs-h="placeholder.h" :gs-min-h="placeholder.minH" :gs-max-h="placeholder.maxH" :gs-no-resize="placeholder.disableResize" :gs-no-move="placeholder.disableDrag")
+ .grid-stack-item-content
+ .content.tw-relative.tw-overflow-hidden(:class="i !== placeholders.length - 1 ? 'tw-mb-3' : ''")
+ .tw-w-full.tw-p-6.disk-card
+ .tw-text-xs.tw-p-0.v-text-field__suffix(v-if="placeholder.id === 'freeSpace'") {{ $t("free_disk_space") }}
+ .tw-text-xs.tw-p-0.v-text-field__suffix(v-if="placeholder.id === 'usedByRecordings'") {{ $t("recordings") }}
+ .tw-h-full.tw-w-full
+ .tw-flex.tw-items-end
+ .tw-text-5xl.tw-font-black.tw-mt-2.text-default(v-if="placeholder.id === 'freeSpace'") 70
+ .tw-text-5xl.tw-font-black.tw-mt-2.text-default(v-if="placeholder.id === 'usedByRecordings'") 5
+ .tw-text-2xl.tw-font-medium.tw-ml-2(style="opacity: 0.7") GB
+ v-progress-linear.tw-mt-3(:value="placeholder.id === 'freeSpace' ? 70 : 5" :buffer-value="100" height="7" rounded color="var(--cui-primary)")
+ .tw-flex.tw-justify-end
+ .tw-text-base.tw-font-this.tw-font-xs(style="opacity: 0.5") 100 GB
+
+
+
+
+
+
diff --git a/ui/src/widgets/disk/widget.vue b/ui/src/widgets/disk/widget.vue
new file mode 100644
index 00000000..801f70f8
--- /dev/null
+++ b/ui/src/widgets/disk/widget.vue
@@ -0,0 +1,101 @@
+
+.content.tw-overflow-y-hidden
+ .tw-flex.tw-justify-between.tw-mt-1.tw-relative.tw-z-5(style="height: 25px;")
+ .tw-ml-2.tw-text-xs.tw-font-bold.text-muted {{ item.id === 'freeSpace' ? sections[0].label : sections[1].label }}
+ .tw-ml-auto.tw-mr-2
+ v-btn.text-muted(icon x-small @click="reloadDiskSpace" style="margin-top: -5px;")
+ v-icon {{ icons['mdiReload'] }}
+
+ .tw-h-full.tw-w-full.tw-flex.tw-items-center.tw-justify-center(v-if="loading || loadingDisk")
+ v-progress-circular(indeterminate color="var(--cui-primary)" size="20")
+ .tw-flex.tw-justify-center.tw-items-center.tw-w-full.disk-card.tw-px-5(v-else style="height: calc(100% - 25px")
+ .tw-w-full
+ .tw-flex.tw-items-end
+ .tw-text-4xl.tw-font-black.tw-mt-2.text-default(v-if="item.id === 'freeSpace'") {{ sections[0].value }}
+ .tw-text-2xl.tw-font-black.tw-mt-2.text-default(v-if="item.id === 'usedByRecordings'") {{ sections[1].value }}
+ .tw-text-2xl.tw-font-medium.tw-ml-2(style="opacity: 0.7") GB
+ v-progress-linear.tw-mt-3(:value="item.id === 'freeSpace' ? sections[0].value : sections[1].value" :buffer-value="maxSize" height="7" rounded color="var(--cui-primary)")
+ .tw-flex.tw-justify-end
+ .tw-text-base.tw-font-this.tw-font-xs(style="opacity: 0.5") {{ maxSize }} GB
+
+
+
+
+
diff --git a/ui/src/widgets/index.js b/ui/src/widgets/index.js
deleted file mode 100644
index 3c6902f6..00000000
--- a/ui/src/widgets/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const widgets = ['time', 'weather'];
-
-export default widgets;