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 @@ + + + + + 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 @@ + + + + + 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;