New version 2.1

This commit is contained in:
Chrystian Huot 2019-11-26 09:08:50 -05:00
parent 004904759b
commit 45611b9293
14 changed files with 124 additions and 220 deletions

View file

@ -1,4 +1,4 @@
# Rdio Scanner v2.0 # Rdio Scanner v2.1
*Rdio Scanner* is a progressive web interface designed to resemble an old school radio scanner. It integrates all frontend / backend components to manage audio files from different sources. *Rdio Scanner* is a progressive web interface designed to resemble an old school radio scanner. It integrates all frontend / backend components to manage audio files from different sources.
@ -8,7 +8,9 @@ For now, only [Trunk Recorder](https://github.com/robotastic/trunk-recorder) sof
## What's new in this version ## What's new in this version
The version 2.0 of *Rdio Scanner* is a major rewrite in which Meteor has been replaced by [The Apollo Data Graph Platform](https://www.apollographql.com/) for its API. MongoDB is also replaced by [SQLite](https://www.sqlite.org/) to facilitate the entire installation process. It is still possible to use another database by changing [Sequelize ORM](https://sequelize.org/) settings accordingly. Version 2.1 focuses on various speed improvements for searching stored calls.
Version 2.0 is a major rewrite in which Meteor has been replaced by [The Apollo Data Graph Platform](https://www.apollographql.com/) for its API. MongoDB is also replaced by [SQLite](https://www.sqlite.org/) to facilitate the entire installation process. It is still possible to use another database by changing [Sequelize ORM](https://sequelize.org/) settings accordingly.
These changes bring many *performance benefits* to *Rdio scanner* and make it *much easier to install*. These changes bring many *performance benefits* to *Rdio scanner* and make it *much easier to install*.

View file

@ -1,6 +1,6 @@
{ {
"name": "rdio-scanner-client", "name": "rdio-scanner-client",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "ng build --cross-origin use-credentials --prod $*", "build": "ng build --cross-origin use-credentials --prod $*",
@ -38,7 +38,7 @@
"@angular/cli": "~8.3.19", "@angular/cli": "~8.3.19",
"@angular/compiler-cli": "~8.2.14", "@angular/compiler-cli": "~8.2.14",
"@angular/language-service": "~8.2.14", "@angular/language-service": "~8.2.14",
"@types/node": "~12.12.8", "@types/node": "~12.12.14",
"codelyzer": "^5.2.0", "codelyzer": "^5.2.0",
"ts-node": "~8.5.2", "ts-node": "~8.5.2",
"tslint": "~5.20.1", "tslint": "~5.20.1",

View file

@ -46,18 +46,16 @@ export class AppRdioScannerCallsQueryService extends Query<RdioScannerCallsQuery
document = gql` document = gql`
query rdioScannerCalls( query rdioScannerCalls(
$date: Date $date: Date
$first: Int $limit: Int
$last: Int $offset: Int
$skip: Int
$sort: Int $sort: Int
$system: Int $system: Int
$talkgroup: Int $talkgroup: Int
) { ) {
rdioScannerCalls( rdioScannerCalls(
date: $date date: $date
first: $first limit: $limit
last: $last offset: $offset
skip: $skip
sort: $sort sort: $sort
system: $system system: $system
talkgroup: $talkgroup talkgroup: $talkgroup
@ -67,21 +65,8 @@ export class AppRdioScannerCallsQueryService extends Query<RdioScannerCallsQuery
dateStop dateStop
results { results {
id id
emergency
freq freq
freqList {
errorCount
freq
len
pos
spikeCount
}
startTime startTime
stopTime
srcList {
pos
src
}
system system
talkgroup talkgroup
} }

View file

@ -76,7 +76,7 @@
<mat-progress-bar color="primary" [mode]="searchResultsPending ? 'query' : 'determinate'"> <mat-progress-bar color="primary" [mode]="searchResultsPending ? 'query' : 'determinate'">
</mat-progress-bar> </mat-progress-bar>
<mat-paginator [disabled]="searchResultsPending" [length]="searchResultsCount" [hidePageSize]="true" <mat-paginator [disabled]="searchResultsPending" [length]="searchResultsCount" [hidePageSize]="true"
pageSize="10" [showFirstLastButtons]="true"> [pageSize]="searchResults.value.length" [showFirstLastButtons]="true">
</mat-paginator> </mat-paginator>
</div> </div>
<form class="rdio-search-form" [formGroup]="searchForm" autocomplete="off"> <form class="rdio-search-form" [formGroup]="searchForm" autocomplete="off">

View file

@ -1,8 +1,7 @@
import { AfterViewInit, Component, HostListener, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, HostListener, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatTable, MatTableDataSource } from '@angular/material/table'; import { BehaviorSubject, Subscription } from 'rxjs';
import { Subscription } from 'rxjs';
import { AppRdioScannerCallQueryService } from './rdio-scanner-call-query.service'; import { AppRdioScannerCallQueryService } from './rdio-scanner-call-query.service';
import { AppRdioScannerCallSubscriptionService, RdioScannerCall } from './rdio-scanner-call-subscription.service'; import { AppRdioScannerCallSubscriptionService, RdioScannerCall } from './rdio-scanner-call-subscription.service';
import { AppRdioScannerCallsQueryService } from './rdio-scanner-calls-query.service'; import { AppRdioScannerCallsQueryService } from './rdio-scanner-calls-query.service';
@ -24,7 +23,7 @@ interface RdioScannerSelection {
styleUrls: ['./rdio-scanner.component.scss'], styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html', templateUrl: './rdio-scanner.component.html',
}) })
export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit { export class AppRdioScannerComponent implements OnDestroy, OnInit {
get call() { return this._call; } get call() { return this._call; }
get callHistory() { return this._callHistory; } get callHistory() { return this._callHistory; }
get callPrevious() { return this._callPrevious; } get callPrevious() { return this._callPrevious; }
@ -82,7 +81,7 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
private _searchFormDateStart = ''; private _searchFormDateStart = '';
private _searchFormDateStop = ''; private _searchFormDateStop = '';
private _searchPanelOpened = false; private _searchPanelOpened = false;
private _searchResults = new MatTableDataSource<RdioScannerCall>(); private _searchResults = new BehaviorSubject(new Array<RdioScannerCall>(10));
private _searchResultsCount = 0; private _searchResultsCount = 0;
private _searchResultsPending = false; private _searchResultsPending = false;
private _selection: RdioScannerSelection = {}; private _selection: RdioScannerSelection = {};
@ -98,10 +97,12 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
private livefeedSubscription: Subscription; private livefeedSubscription: Subscription;
private searchFormSubscription: Subscription; private searchFormSubscription: Subscription;
private searchPaginatorSubscription: Subscription; private searchPaginatorSubscription: Subscription;
private searchResultsBuffer = new Array<RdioScannerCall>(200);
private searchResultsBufferFrom = 0;
private searchResultsBufferTo = this.searchResultsBuffer.length - 1;
private systemsSubscription: Subscription; private systemsSubscription: Subscription;
@ViewChild(MatPaginator, { static: true }) private matPaginator: MatPaginator; @ViewChild(MatPaginator, { static: true }) private matPaginator: MatPaginator;
@ViewChild(MatTable, { static: true }) private matTable: MatTable<RdioScannerCall>;
constructor( constructor(
private appRdioScannerCallQuery: AppRdioScannerCallQueryService, private appRdioScannerCallQuery: AppRdioScannerCallQueryService,
@ -282,19 +283,13 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
loadAndPlay(call: RdioScannerCall): void { loadAndPlay(call: RdioScannerCall): void {
this.appRdioScannerCallQuery.fetch({ id: call.id }).subscribe(({ data }) => { this.appRdioScannerCallQuery.fetch({ id: call.id }).subscribe(({ data }) => {
if (data.rdioScannerCall) { if (data.rdioScannerCall) {
Object.assign(call, data.rdioScannerCall); Object.assign(call, this.transformCall(data.rdioScannerCall));
this.play(call); this.play(call);
} }
}); });
} }
ngAfterViewInit(): void {
if (this.matPaginator instanceof MatPaginator) {
this.searchResults.paginator = this.matPaginator;
}
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.unsubscribeLivefeed(); this.unsubscribeLivefeed();
@ -314,7 +309,6 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
this.subscribeSystems().then(() => { this.subscribeSystems().then(() => {
this.subscribeSearchForm(); this.subscribeSearchForm();
this.subscribeSearchPaginator(); this.subscribeSearchPaginator();
}); });
} }
@ -430,7 +424,7 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
toggleSearchPanel(opened = !this.searchPanelOpened): void { toggleSearchPanel(opened = !this.searchPanelOpened): void {
if (opened) { if (opened) {
this.searchCalls(); this.loadStoredCalls();
} }
this._searchPanelOpened = opened; this._searchPanelOpened = opened;
@ -525,97 +519,70 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
}, this.selection || {}); }, this.selection || {});
} }
private searchCalls(paginator: { private async loadStoredCalls(filters?: any): Promise<void> {
first?: boolean | number; const limit = this.searchResultsBuffer.length;
last?: boolean | number;
skip?: number;
} = {}): void {
const options: {
date?: Date;
first?: number;
last?: number;
skip?: number;
sort?: number;
system?: number;
talkgroup?: number;
} = {};
const count = 200; const pageFrom = this.matPaginator.pageIndex * this.matPaginator.pageSize;
const form = this.searchForm.value; const pageTo = this.matPaginator.pageIndex * this.matPaginator.pageSize + this.matPaginator.pageSize - 1;
if (form.date instanceof Date) { if (!this.searchResultsPending && (filters || !pageFrom ||
options.date = form.date; pageFrom < this.searchResultsBufferFrom || pageTo > this.searchResultsBufferTo)) {
}
if (typeof paginator.first === 'boolean') { filters = filters || this.searchForm.value;
options.first = count;
} else if (typeof paginator.first === 'number') { this.searchResultsBufferFrom = Math.floor(pageFrom / limit) * limit;
options.first = paginator.first; this.searchResultsBufferTo = this.searchResultsBufferFrom + limit - 1;
} else if (typeof paginator.last === 'boolean') { const options: {
options.last = count; date?: Date;
limit?: number;
offset?: number;
sort?: number;
system?: number;
talkgroup?: number;
} = {};
} else if (typeof paginator.last === 'number') { if (filters.date instanceof Date) {
options.last = paginator.last; options.date = filters.date;
} else {
options.first = count;
}
if (typeof paginator.skip === 'number' && paginator.skip > 0) {
options.skip = paginator.skip;
}
if (typeof form.sort === 'number') {
options.sort = form.sort;
}
if (typeof form.system === 'number' && form.system !== -1) {
options.system = form.system;
}
if (typeof form.talkgroup === 'number' && form.talkgroup !== -1) {
options.talkgroup = form.talkgroup;
}
this._searchResultsPending = true;
this.searchForm.disable();
this.appRdioScannerCallsQuery.fetch(options, { fetchPolicy: 'no-cache' }).subscribe(({ data }) => {
const results = data.rdioScannerCalls.results.map((call) => this.transformCall(call));
if (this.searchResults.data.length < data.rdioScannerCalls.count) {
const ar = new Array<RdioScannerCall>(data.rdioScannerCalls.count - this.searchResults.data.length);
if (options.sort < 0) {
this.searchResults.data.unshift(...ar);
} else if (options.sort > 0) {
this.searchResults.data.push(...ar);
}
} }
if (options.last) { if (limit) {
this.searchResults.data.splice((options.skip || this.searchResults.data.length) - results.length, options.limit = limit;
results.length, ...results);
} else {
this.searchResults.data.splice(options.skip || 0, results.length, ...results);
} }
this._searchResultsCount = data.rdioScannerCalls.count; if (this.searchResultsBufferFrom) {
this._searchFormDateStart = data.rdioScannerCalls.dateStart; options.offset = this.searchResultsBufferFrom;
this._searchFormDateStop = data.rdioScannerCalls.dateStop; }
this.matTable.renderRows(); if (filters.sort < 0) {
this.searchResults.data = this.searchResults.data.slice(); // because renderRow() doesn't want to work options.sort = -1;
}
this.searchForm.enable(); if (filters.system >= 0) {
options.system = filters.system;
}
if (filters.talkgroup >= 0) {
options.talkgroup = filters.talkgroup;
}
this._searchResultsPending = true;
const query = await this.appRdioScannerCallsQuery.fetch(options, { fetchPolicy: 'no-cache' }).toPromise();
for (let i = 0; i < limit; i++) {
this.searchResultsBuffer[i] = this.transformCall(query.data.rdioScannerCalls.results[i]);
}
this._searchResultsCount = query.data.rdioScannerCalls.count;
this._searchFormDateStart = query.data.rdioScannerCalls.dateStart;
this._searchFormDateStop = query.data.rdioScannerCalls.dateStop;
this._searchResultsPending = false; this._searchResultsPending = false;
}); }
this.searchResults.next(this.searchResultsBuffer.slice(pageFrom % limit, pageTo % limit + 1));
} }
private async subscribeLivefeed(): Promise<void> { private async subscribeLivefeed(): Promise<void> {
@ -632,23 +599,17 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
if (!this.searchFormSubscription) { if (!this.searchFormSubscription) {
this.searchFormSubscription = this.searchForm.valueChanges.subscribe((value) => { this.searchFormSubscription = this.searchForm.valueChanges.subscribe((value) => {
if (!this.searchResultsPending) { if (!Object.keys(value).every((key) => value[key] === lastValue[key])) {
if (!Object.keys(value).every((key) => value[key] === lastValue[key])) { lastValue = value;
if (value.system !== lastValue.system) {
this.searchForm.get('talkgroup').setValue(-1, { emitEvent: false });
}
lastValue = this.searchForm.value; if (value.system !== lastValue.system && value.talkgroup !== -1) {
lastValue.talkgroup = -1;
this.searchResults.data.splice(0, this.searchResults.data.length, this.searchForm.get('talkgroup').reset(lastValue.talkgroup);
...new Array<RdioScannerCall>(this.matPaginator.pageSize));
this.searchCalls();
if (this.matPaginator.pageIndex > 0) {
this.matPaginator.firstPage();
}
} }
this.matPaginator.firstPage();
this.loadStoredCalls(value);
} }
}); });
} }
@ -656,43 +617,7 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
private subscribeSearchPaginator(): void { private subscribeSearchPaginator(): void {
if (!this.searchPaginatorSubscription) { if (!this.searchPaginatorSubscription) {
this.searchPaginatorSubscription = this.matPaginator.page.subscribe((event: PageEvent) => { this.searchPaginatorSubscription = this.matPaginator.page.subscribe(() => this.loadStoredCalls());
const sortOrder = this.searchForm.get('sort').value;
if (sortOrder < 0 && event.pageIndex === 0) {
this.searchCalls({ first: true });
} else if (sortOrder > 0 && event.pageIndex >= event.length / event.pageSize - 1) {
this.searchCalls({ last: true });
} else if (event.length - event.pageIndex * event.pageSize < event.pageSize) {
const needMore = !this.searchResults.data
.slice(event.pageIndex * event.pageSize, event.pageIndex * event.pageSize + event.pageSize)
.every((call) => call);
if (needMore) {
this.searchCalls({ last: true, skip: event.length });
}
} else if (event.pageIndex > event.previousPageIndex) {
const needMore = !this.searchResults.data
.slice(event.pageIndex * event.pageSize, event.pageIndex * event.pageSize + event.pageSize)
.every((call) => call);
if (needMore) {
this.searchCalls({ first: true, skip: event.pageIndex * event.pageSize });
}
} else if (event.pageIndex < event.previousPageIndex) {
const needMore = !this.searchResults.data
.slice(event.pageIndex * event.pageSize - 1, event.pageIndex * event.pageSize + event.pageSize - 1)
.every((call) => call);
if (needMore) {
this.searchCalls({ last: true, skip: event.pageIndex * event.pageSize + event.pageSize - 1 });
}
}
});
} }
} }
@ -707,11 +632,13 @@ export class AppRdioScannerComponent implements AfterViewInit, OnDestroy, OnInit
} }
private transformCall(call: RdioScannerCall): RdioScannerCall { private transformCall(call: RdioScannerCall): RdioScannerCall {
call.systemData = (call && this.systems if (call) {
.find((system: RdioScannerSystem) => system.system === call.system)) || {}; call.systemData = (call && this.systems
.find((system: RdioScannerSystem) => system.system === call.system)) || {};
call.talkgroupData = (call && call.systemData && Array.isArray(call.systemData.talkgroups) && call.systemData.talkgroups call.talkgroupData = (call && call.systemData && Array.isArray(call.systemData.talkgroups) && call.systemData.talkgroups
.find((talkgroup: RdioScannerTalkgroup) => talkgroup.dec === call.talkgroup)) || {}; .find((talkgroup: RdioScannerTalkgroup) => talkgroup.dec === call.talkgroup)) || {};
}
return call; return call;
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "rdio-scanner", "name": "rdio-scanner",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"main": "run.js", "main": "run.js",
"scripts": { "scripts": {

View file

@ -1,7 +1,6 @@
'use strict'; 'use strict';
const { DataSource } = require('apollo-datasource'); const { DataSource } = require('apollo-datasource');
const { getPageRange } = require('../../helpers/paginator');
const { callReducer } = require('../../helpers/rdio-scanner'); const { callReducer } = require('../../helpers/rdio-scanner');
class RdioScannerSystem extends DataSource { class RdioScannerSystem extends DataSource {
@ -21,7 +20,7 @@ class RdioScannerSystem extends DataSource {
return callReducer(result.dataValues); return callReducer(result.dataValues);
} }
async getCalls({ date, system, talkgroup }, { first, last, skip, sort }) { async getCalls({ date, system, talkgroup }, { limit, offset, sort }) {
const Op = this.store.Sequelize.Op; const Op = this.store.Sequelize.Op;
const attributes = { const attributes = {
@ -59,7 +58,8 @@ class RdioScannerSystem extends DataSource {
const count = await this.store.rdioScannerCall.count({ where }); const count = await this.store.rdioScannerCall.count({ where });
const { limit, offset } = getPageRange({ count, first, last, skip }); limit = typeof limit === 'number' ? limit : 100;
offset = typeof offset === 'number' ? offset : 0;
const calls = await this.store.rdioScannerCall.findAll({ attributes, limit, offset, order, where }); const calls = await this.store.rdioScannerCall.findAll({ attributes, limit, offset, order, where });

View file

@ -1,8 +1,7 @@
rdioScannerCalls( rdioScannerCalls(
date: Date date: Date
first: Int limit: Int
last: Int offset: Int
skip: Int
sort: Int sort: Int
system: Int system: Int
talkgroup: Int talkgroup: Int

View file

@ -1,7 +1,7 @@
'use strict'; 'use strict';
module.exports = () => async (_, { date, first, last, skip, sort, system, talkgroup }, { dataSources }) => { module.exports = () => async (_, { date, limit, offset, sort, system, talkgroup }, { dataSources }) => {
const { count, dateStart, dateStop, results } = await dataSources.rdioScannerCall.getCalls({ date, system, talkgroup }, { first, last, skip, sort }); const { count, dateStart, dateStop, results } = await dataSources.rdioScannerCall.getCalls({ date, system, talkgroup }, { limit, offset, sort });
return { count, dateStart, dateStop, results }; return { count, dateStart, dateStop, results };
}; };

View file

@ -1,31 +0,0 @@
'use strict';
function getPageRange({ count, first, last, skip }) {
const page = {
default: 100,
max: 1000,
};
let limit;
let offset;
first = typeof first === 'number' && first > 0 ? Math.min(page.max, first) : typeof last !== 'number' ? page.default : null;
last = typeof last === 'number' && last > 0 ? Math.min(page.max, last) : null;
skip = typeof skip === 'number' && skip > 0 ? skip : 0;
if (typeof first === 'number') {
limit = Math.max(1, Math.min(count, skip + first) - skip);
offset = Math.min(count - 1, skip);
} else {
limit = count - Math.max(0, Math.min(count, skip) - last);
offset = Math.max(0, Math.max(count, skip) - last);
}
return { limit, offset };
}
module.exports = { getPageRange };

View file

@ -59,5 +59,5 @@ module.exports = {
throw err; throw err;
} }
} },
}; };

View file

@ -87,5 +87,5 @@ module.exports = {
throw err; throw err;
} }
} },
}; };

View file

@ -0,0 +1,22 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeIndex('rdioScannerCalls', ['system'], { transaction });
await queryInterface.removeIndex('rdioScannerCalls', ['talkgroup'], { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View file

@ -1,6 +1,6 @@
{ {
"name": "rdio-scanner-server", "name": "rdio-scanner-server",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -11,7 +11,7 @@
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"apollo-datasource": "^0.6.3", "apollo-datasource": "^0.6.3",
"apollo-server-express": "^2.9.9", "apollo-server-express": "^2.9.12",
"camelcase": "^5.3.1", "camelcase": "^5.3.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@ -20,15 +20,15 @@
"helmet": "^3.21.2", "helmet": "^3.21.2",
"mariadb": "^2.1.3", "mariadb": "^2.1.3",
"multer": "^1.4.2", "multer": "^1.4.2",
"mysql2": "^2.0.0", "mysql2": "^2.0.1",
"nodemon": "^1.19.4", "nodemon": "^2.0.1",
"pg": "^7.12.1", "pg": "^7.14.0",
"sequelize": "^5.21.2", "sequelize": "^5.21.2",
"sequelize-cli": "^5.5.1", "sequelize-cli": "^5.5.1",
"sqlite3": "^4.1.0", "sqlite3": "^4.1.0",
"uuid": "^3.3.3" "uuid": "^3.3.3"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^6.6.0" "eslint": "^6.7.1"
} }
} }