Version 5.1.0

This commit is contained in:
Chrystian Huot 2021-05-18 09:42:49 -04:00
parent 4fbed0acb0
commit 07e7a563bb
185 changed files with 10481 additions and 5742 deletions

View file

@ -1,3 +1,21 @@
# Version 5.1
This one is a big one... **Be sure to backup your config.json and your database.sqlite before updating.**
- With the exception of some parameters like the SSL certificates, all configurations have been moved to an administrative dashboard for easier configuration. No more config.json editing!
- Access codes can now be set with a limit of simultaneous connections. It is also possible to configure an expiration date for each access codes.
- Auto populate option can now be set per system in addition to globally.
- Duplicate call detection is now optional and can be disabled from the options section of the administrative dashboard.
- On a per system basis, it is now possible to blacklist certain talkgroup IDs against ingestion.
- Groups and tags are now defined in their own section, then linked to talkgroup definitions.
- Server logs are now stored in the database and accessed through the administrative dashboard, in addition to the standard output.
- Talkgroups CSV files can now be loaded in from the administrative dashboard.
- Server configuration can be exported/imported to/from a JSON file.
- The downstream id_as property is gone due to its complexity of implementation with the new systems/talkgroups selection dialog for access codes, downstreams and apikeys.
- The keyboard shortcuts are a thing of the past. They caused conflicts with other features.
- Minor changes to the webapp look, less rounded.
- Talkgroup buttons label now wraps on 2 lines.
# Version 5.0
- Add rdioScanner.options.autoPopulate which by default is true. The configuration file will now be automatically populated from new received calls with unknown system/talkgroup.

View file

@ -17,8 +17,9 @@ FROM node:lts-alpine
LABEL maintainer="Chrystian Huot <chrystian.huot@saubeo.solutions>"
WORKDIR /app
ENV APP_DATA=/app/data \
APP_PORT=3000
RUN apk --no-cache --no-progress add ffmpeg sqlite tzdata && \
APP_PORT=3000 \
DOCKER=1
RUN apk --no-cache --no-progress add ffmpeg openssl sqlite tzdata && \
mkdir -p "${APP_DATA}"
COPY LICENSE .
COPY --from=client-build /app/client/dist/rdio-scanner/. client/.

View file

@ -10,12 +10,12 @@ The client web app is responsive and also works well on mobile devices.
[Rdio Scanner](https://github.com/chuot/rdio-scanner) has all the backend and frontend components to deal with your audio files.
The server side of [Rdio Scanner](https://github.com/chuot/rdio-scanner) can monitor directories for audio files to be ingested, or has an API to upload audio files from a remote (or local) location. Those files are associated to defined systems/talkgroups from the configuration file, then stored locally in its database. The web application clients are notified of these new audio files based on their own systems/talkgroups selection. It is also possible for web application clients to browse for archived audio files and replay them in an offline manner, or download them individually.
The server side of [Rdio Scanner](https://github.com/chuot/rdio-scanner) can monitor directories for audio files to be ingested, or has an API to upload audio files from a remote (or local) location. Those files are associated to defined systems/talkgroups in the server's configuration, then stored locally in its database. The web application clients are notified of these new audio files based on their own systems/talkgroups selection. It is also possible for clients to browse for archived audio files and replay them in an offline manner, or download them individually.
## Main features
- It's a progressive web app that also works great on mobile devices.
- Simplistic interface, but strong usability.
- Administrative dashboard for easy server configuration.
- Configurable systems and talkgroups.
- Talkgroups from any systems can be grouped together for better selection of live streams.
- Access control via password with possible restriction to systems/talkgroups.
@ -45,10 +45,16 @@ The server side of [Rdio Scanner](https://github.com/chuot/rdio-scanner) can mon
- [docs/examples](./docs/examples) - Some miscellaneous examples
- [CONTRIBUTING.md](./CONTRIBUTING.md) - Contribution guidelines
> Raspberry Pi users can use this [script](https://github.com/chuot/rdio-scanner-pi-setup) to simplify the installation procedure.
## Questions / Comments
You can ask your questions or post your comments on the [Gitter Rdio Scanner Lobby](https://gitter.im/rdio-scanner/Lobby) where the community continues to grow.
## Commercial support
Commercial support is available at https://saubeo.solutions/.
## Show your appreciation / Support the author
If you like [Rdio Scanner](https://github.com/chuot/rdio-scanner) please consider **starring the repository** to show you appreciation to the author for his hard work. It cost nothing but is really appreciated.

36
client/.eslintrc.json Normal file
View file

@ -0,0 +1,36 @@
{
"env": {
"commonjs": true,
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {
"linebreak-style": [
"error",
"unix"
],
"max-len": [
"error",
{
"code": 140
}
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

View file

@ -15,7 +15,7 @@
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"prefix": "rdio-scanner",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
@ -75,7 +75,7 @@
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "rdio-scanner:build",
"proxyConfig": "proxy.conf.js"
"proxyConfig": "src/proxy.conf.js"
},
"configurations": {
"production": {

View file

@ -27,4 +27,4 @@
}
}
]
}
}

3387
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,32 @@
{
"author": "Chrystian Huot <chrystian.huot@saubeo.solutions>",
"dependencies": {
"@angular/animations": "^11.2.8",
"@angular/cdk": "^11.2.7",
"@angular/common": "~11.2.8",
"@angular/compiler": "~11.2.8",
"@angular/core": "~11.2.8",
"@angular/forms": "~11.2.8",
"@angular/material": "^11.2.7",
"@angular/platform-browser": "~11.2.8",
"@angular/platform-browser-dynamic": "~11.2.8",
"@angular/service-worker": "~11.2.8",
"@angular/animations": "^11.2.14",
"@angular/cdk": "^11.2.12",
"@angular/common": "~11.2.14",
"@angular/compiler": "~11.2.14",
"@angular/core": "~11.2.14",
"@angular/forms": "~11.2.14",
"@angular/material": "^11.2.12",
"@angular/platform-browser": "~11.2.14",
"@angular/platform-browser-dynamic": "~11.2.14",
"@angular/router": "^11.2.14",
"@angular/service-worker": "^11.2.14",
"rxjs": "~6.6.7",
"tslib": "^2.1.0",
"tslib": "^2.2.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1102.7",
"@angular/cli": "^11.2.7",
"@angular/compiler-cli": "~11.2.8",
"@types/node": "~14.14.37",
"codelyzer": "^6.0.1",
"@angular-devkit/build-angular": "~0.1102.13",
"@angular/cli": "^11.2.10",
"@angular/compiler-cli": "~11.2.11",
"@types/node": "~14.14.41",
"ts-node": "~9.1.1",
"tslint": "~6.1.3",
"typescript": "~4.1.5"
},
"engines": {
"node": ">=14.0"
},
"license": "LICENSE",
"name": "rdio-scanner-client",
"private": true,
@ -32,5 +34,5 @@
"build": "ng build --base-href ./ --prod",
"start": "ng serve"
},
"version": "5.0.5"
"version": "5.1.0"
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1 +1 @@
<app-rdio-scanner></app-rdio-scanner>
<router-outlet></router-outlet>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,18 +23,25 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AppComponent } from './app.component';
import { AppRdioScannerModule } from './components/rdio-scanner/rdio-scanner.module';
import { RdioScannerModule } from './components/rdio-scanner/rdio-scanner.module';
import { AppSharedModule } from './shared/shared.module';
import { routes } from './app.routes';
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [
AppRdioScannerModule,
AppSharedModule.forRoot(),
RdioScannerModule,
AppSharedModule.forRoot({
routerExtraOptions: { enableTracing: false },
routerRoutes: routes,
}),
BrowserAnimationsModule,
BrowserModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
],
})
export class AppModule { }

View file

@ -0,0 +1,19 @@
/**
* @license
* Copyright Saubeo Solutions All Rights Reserved
*
* This source code is proprietary and confidential
* Unauthorized copying of this file, via any medium is strictly prohibited
*/
import { Routes } from '@angular/router';
import { routes as rdioScannerRoutes } from './pages/rdio-scanner';
export const routes: Routes = [
...rdioScannerRoutes,
{
path: '**',
pathMatch: 'full',
redirectTo: '',
},
];

View file

@ -0,0 +1,43 @@
<ng-container *ngIf="!authenticated">
<rdio-scanner-admin-login></rdio-scanner-admin-login>
</ng-container>
<ng-container *ngIf="authenticated">
<rdio-scanner-admin-todos></rdio-scanner-admin-todos>
<mat-accordion displayMode="flat">
<mat-expansion-panel #configPanel (afterCollapse)="configComponent?.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>settings</mat-icon>
Config
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
</mat-expansion-panel>
<mat-expansion-panel (afterExpand)="logsComponent.reload()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>article</mat-icon>
Logs
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="toolsComponent?.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>build</mat-icon>
Tools
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
</mat-expansion-panel>
<mat-expansion-panel hideToggle (click)="logout()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>logout</mat-icon>
Logout
</mat-panel-title>
</mat-expansion-panel-header>
</mat-expansion-panel>
</mat-accordion>
</ng-container>

View file

@ -0,0 +1,38 @@
rdio-scanner-admin {
.mat-expansion-panel-header {
user-select: none;
}
.mat-expansion-panel-header-title {
align-items: center;
.mat-icon:not(.mat-warn):first-of-type {
color: rgba(0, 0, 0, 0.7);
margin-right: 12px;
}
}
}
@media (max-width: 719px) {
rdio-scanner-admin {
.mat-expansion-panel-header {
padding: 0 0.5rem;
}
.mat-expansion-panel-body {
padding: 0 0.5rem 16px;
}
}
}
@media (min-width: 720px) {
rdio-scanner-admin {
.mat-expansion-panel-header {
padding: 0 1rem;
}
.mat-expansion-panel-body {
padding: 0 1rem 16px;
}
}
}

View file

@ -0,0 +1,51 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, OnDestroy, ViewEncapsulation } from '@angular/core';
import { AdminEvent, RdioScannerAdminService, Group, Tag } from './admin.service';
@Component({
encapsulation: ViewEncapsulation.None,
selector: 'rdio-scanner-admin',
styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html',
})
export class RdioScannerAdminComponent implements OnDestroy {
authenticated = this.adminService.authenticated;
groups: Group[] = [];
tags: Tag[] = [];
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event) {
this.authenticated = event.authenticated || false;
}
});
constructor(private adminService: RdioScannerAdminService) { }
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
}
async logout(): Promise<void> {
await this.adminService.logout();
}
}

View file

@ -0,0 +1,75 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { AppSharedModule } from '../../../shared/shared.module';
import { RdioScannerAdminComponent } from './admin.component';
import { RdioScannerAdminService } from './admin.service';
import { RdioScannerAdminConfigComponent } from './config/config.component';
import { RdioScannerAdminAccessComponent } from './config/access/access.component';
import { RdioScannerAdminApiKeysComponent } from './config/api-keys/api-keys.component';
import { RdioScannerAdminDirWatchComponent } from './config/dir-watch/dir-watch.component';
import { RdioScannerAdminDownstreamsComponent } from './config/downstreams/downstreams.component';
import { RdioScannerAdminGroupsComponent } from './config/groups/groups.component';
import { RdioScannerAdminOptionsComponent } from './config/options/options.component';
import { RdioScannerAdminSystemsSelectComponent } from './config/systems/select/select.component';
import { RdioScannerAdminSystemComponent } from './config/systems/system/system.component';
import { RdioScannerAdminSystemsComponent } from './config/systems/systems.component';
import { RdioScannerAdminTalkgroupComponent } from './config/systems/talkgroup/talkgroup.component';
import { RdioScannerAdminUnitComponent } from './config/systems/unit/unit.component';
import { RdioScannerAdminTagsComponent } from './config/tags/tags.component';
import { RdioScannerAdminLoginComponent } from './login/login.component';
import { RdioScannerAdminLogsComponent } from './logs/logs.component';
import { RdioScannerAdminTodosComponent } from './todos/todos.component';
import { RdioScannerAdminToolsComponent } from './tools/tools.component';
import { RdioScannerAdminImportExportConfigComponent } from './tools/import-export-config/import-export-config.component';
import { RdioScannerAdminImportCsvComponent } from './tools/import-csv/import-csv.component';
import { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
@NgModule({
declarations: [
RdioScannerAdminComponent,
RdioScannerAdminConfigComponent,
RdioScannerAdminAccessComponent,
RdioScannerAdminApiKeysComponent,
RdioScannerAdminDirWatchComponent,
RdioScannerAdminDownstreamsComponent,
RdioScannerAdminGroupsComponent,
RdioScannerAdminImportExportConfigComponent,
RdioScannerAdminImportCsvComponent,
RdioScannerAdminLoginComponent,
RdioScannerAdminLogsComponent,
RdioScannerAdminOptionsComponent,
RdioScannerAdminPasswordComponent,
RdioScannerAdminSystemComponent,
RdioScannerAdminSystemsComponent,
RdioScannerAdminSystemsSelectComponent,
RdioScannerAdminTagsComponent,
RdioScannerAdminTalkgroupComponent,
RdioScannerAdminTodosComponent,
RdioScannerAdminToolsComponent,
RdioScannerAdminUnitComponent,
],
entryComponents: [RdioScannerAdminSystemsSelectComponent],
exports: [RdioScannerAdminComponent],
imports: [AppSharedModule, HttpClientModule],
providers: [RdioScannerAdminService],
})
export class RdioScannerAdminModule { }

View file

@ -0,0 +1,726 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { EventEmitter, Injectable, OnDestroy } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { timer } from 'rxjs';
export interface Access {
_id?: string;
code?: string;
expiration?: Date;
ident?: string;
limit?: number;
order?: number;
systems?: {
id: number;
talkgroups: {
id: number;
}[] | number[] | '*';
}[] | number[] | '*';
}
export interface ApiKey {
_id?: string;
disabled?: boolean;
ident?: string;
key?: string;
order?: number;
systems?: {
id: number;
talkgroups: number[] | '*';
}[] | number[] | '*';
}
export interface DirWatch {
_id?: string;
delay?: number;
deleteAfter?: boolean;
directory?: string;
disabled?: boolean;
extension?: string;
frequency?: number;
mask?: string;
order?: number;
systemId?: number;
talkgroupId?: number;
type?: string;
usePolling?: boolean;
}
export interface Downstream {
_id?: string;
apiKey?: string;
disabled?: boolean;
order?: number;
systems?: {
id?: number;
id_as?: number;
talkgroups?: {
id: number;
id_as?: number;
}[] | number[] | '*';
}[] | number[] | '*';
url?: string;
}
export interface Options {
autoPopulate?: boolean;
dimmerDelay?: number;
disableAudioConversion?: boolean;
disableDuplicateDetection?: boolean;
keypadBeeps?: string;
pruneDays?: number;
sortTalkgroups?: boolean;
}
export interface Group {
_id?: number;
label?: string;
}
export interface Log {
_id: number;
dateTime: Date;
level: number;
message: string;
}
export interface LogsQuery {
count: number;
dateStart: Date;
dateStop: Date;
options: LogsQueryOptions;
logs: Log[];
}
export interface LogsQueryOptions {
date?: Date;
level?: 'error' | 'info' | 'warn';
limit: number;
offset: number;
sort: number;
}
export interface Tag {
_id?: number;
label?: string;
}
export interface Talkgroup {
frequency?: number | null;
groupId?: number;
id?: number;
label?: string;
led?: string | null;
name?: string;
patches?: number[];
tagId?: number;
}
export interface Unit {
id?: number | null;
label?: string;
}
export interface System {
_id?: number;
autoPopulate?: boolean;
blacklists?: string;
id?: number;
label?: string;
led?: string | null;
order?: number | null;
talkgroups?: Talkgroup[];
units?: Unit[];
}
export interface Config {
access?: Access[];
apiKeys?: ApiKey[];
dirWatch?: DirWatch[];
docker?: boolean;
downstreams?: Downstream[];
groups?: Group[];
options?: Options;
systems?: System[];
tags?: Tag[];
}
export interface AdminEvent {
authenticated?: boolean;
config?: Config;
passwordNeedChange?: boolean;
}
enum url {
config = 'config',
login = 'login',
logout = 'logout',
logs = 'logs',
password = 'password',
}
const SESSION_STORAGE_KEY = 'rdio-scanner-admin-token';
@Injectable()
export class RdioScannerAdminService implements OnDestroy {
event = new EventEmitter<AdminEvent>();
get authenticated() {
return !!this.token;
}
get passwordNeedChange() {
return this._passwordNeedChange;
}
private configWebSocket: WebSocket | undefined;
private _passwordNeedChange = false;
private get token(): string {
return window?.sessionStorage?.getItem(SESSION_STORAGE_KEY) || '';
}
private set token(token: string) {
window?.sessionStorage?.setItem(SESSION_STORAGE_KEY, token);
}
constructor(
private matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder,
private ngHttpClient: HttpClient,
) {
this.configWebSocketOpen();
}
ngOnDestroy(): void {
this.event.complete();
this.configWebSocketClose();
}
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
try {
const res = await this.ngHttpClient.post<{ passwordNeedChange: boolean }>(
this.getUrl(url.password),
{ currentPassword, newPassword },
{ headers: this.getHeaders(), responseType: 'json' },
).toPromise();
this._passwordNeedChange = res.passwordNeedChange;
this.event.next({ passwordNeedChange: this.passwordNeedChange });
} catch (error) {
this.errorHandler(error);
throw error;
}
}
async getConfig(): Promise<Config> {
try {
const res = await this.ngHttpClient.get<{
config: Config;
passwordNeedChange: boolean;
}>(
this.getUrl(url.config),
{ headers: this.getHeaders(), responseType: 'json' },
).toPromise();
if (res.passwordNeedChange !== this._passwordNeedChange) {
this._passwordNeedChange = res.passwordNeedChange;
this.event.emit({ passwordNeedChange: this.passwordNeedChange });
}
return res.config;
} catch (error) {
this.errorHandler(error);
return {};
}
}
getLeds(): string[] {
return ['blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow'];
}
async getLogs(options: LogsQueryOptions): Promise<LogsQuery | null> {
try {
const res = await this.ngHttpClient.post<LogsQuery>(
this.getUrl(url.logs),
options,
{ headers: this.getHeaders(), responseType: 'json' },
).toPromise();
return res;
} catch (error) {
this.errorHandler(error);
return null;
}
}
async login(password: string): Promise<boolean> {
try {
const res = await this.ngHttpClient.post<{
passwordNeedChange: boolean,
token: string
}>(
this.getUrl(url.login),
{ password },
{ headers: this.getHeaders(), responseType: 'json' },
).toPromise();
this.token = res.token;
this._passwordNeedChange = res.passwordNeedChange;
this.event.emit({
authenticated: this.authenticated,
passwordNeedChange: res.passwordNeedChange,
});
this.configWebSocketOpen();
return !!this.token;
} catch (error) {
this.errorHandler(error);
return false;
}
}
async logout(): Promise<boolean> {
try {
await this.ngHttpClient.post(
this.getUrl(url.logout),
null,
{ headers: this.getHeaders(), responseType: 'text' },
).toPromise();
this.configWebSocketClose();
this.token = '';
this.event.emit({ authenticated: this.authenticated });
return true;
} catch (error) {
this.errorHandler(error);
return false;
}
}
async saveConfig(config: Config): Promise<Config> {
try {
const res = await this.ngHttpClient.put<{ config: Config }>(
this.getUrl(url.config),
config,
{ headers: this.getHeaders(), responseType: 'json' },
).toPromise();
return res.config;
} catch (error) {
this.errorHandler(error);
return config;
}
}
newAccessForm(access?: Access): FormGroup {
return this.ngFormBuilder.group({
_id: [access?._id],
code: [access?.code, [Validators.required, this.validateAccessCode()]],
expiration: [access?.expiration],
ident: [access?.ident, Validators.required],
limit: [access?.limit],
order: [access?.order],
systems: [access?.systems, Validators.required],
});
}
newApiKeyForm(apiKey?: ApiKey): FormGroup {
return this.ngFormBuilder.group({
_id: [apiKey?._id],
disabled: [apiKey?.disabled],
ident: [apiKey?.ident, Validators.required],
key: [apiKey?.key, [Validators.required, this.validateApiKey()]],
order: [apiKey?.order],
systems: [apiKey?.systems, Validators.required],
});
}
newConfigForm(config?: Config): FormGroup {
return this.ngFormBuilder.group({
access: this.ngFormBuilder.array(config?.access?.map((access) => this.newAccessForm(access)) || []),
apiKeys: this.ngFormBuilder.array(config?.apiKeys?.map((apiKey) => this.newApiKeyForm(apiKey)) || []),
dirWatch: this.ngFormBuilder.array(config?.dirWatch?.map((dirWatch) => this.newDirWatchForm(dirWatch)) || []),
downstreams: this.ngFormBuilder.array(config?.downstreams?.map((downstream) => this.newDownstreamForm(downstream)) || []),
groups: this.ngFormBuilder.array(config?.groups?.map((group) => this.newGroupForm(group)) || []),
options: this.newOptionsForm(config?.options),
systems: this.ngFormBuilder.array(config?.systems?.map((system) => this.newSystemForm(system)) || []),
tags: this.ngFormBuilder.array(config?.tags?.map((tag) => this.newTagForm(tag)) || []),
});
}
newDirWatchForm(dirWatch?: DirWatch): FormGroup {
return this.ngFormBuilder.group({
_id: [dirWatch?._id],
delay: [dirWatch?.delay, Validators.min(0)],
deleteAfter: [dirWatch?.deleteAfter],
directory: [dirWatch?.directory, [Validators.required, this.validateDirectory()]],
disabled: [dirWatch?.disabled],
extension: [dirWatch?.extension, this.validateExtension()],
frequency: [dirWatch?.frequency, Validators.min(0)],
mask: [dirWatch?.mask, this.validateMask()],
order: [dirWatch?.order],
systemId: [dirWatch?.systemId, this.validateDirwatchSystemId()],
talkgroupId: [dirWatch?.talkgroupId, this.validateDirwatchTalkgroupId()],
type: [dirWatch?.type],
usePolling: [dirWatch?.usePolling],
});
}
newDownstreamForm(downstream?: Downstream): FormGroup {
return this.ngFormBuilder.group({
_id: [downstream?._id],
apiKey: [downstream?.apiKey, [Validators.required, this.validateApiKey()]],
disabled: [downstream?.disabled],
order: [downstream?.order],
systems: [downstream?.systems, Validators.required],
url: [downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]],
});
}
newGroupForm(group?: Group): FormGroup {
return this.ngFormBuilder.group({
_id: [group?._id],
label: [group?.label, Validators.required],
});
}
newTagForm(tag?: Tag): FormGroup {
return this.ngFormBuilder.group({
_id: [tag?._id],
label: [tag?.label, Validators.required],
});
}
newSystemForm(system?: System): FormGroup {
return this.ngFormBuilder.group({
_id: [system?._id],
autoPopulate: [system?.autoPopulate],
blacklists: [system?.blacklists, this.validateBlacklists()],
id: [system?.id, [Validators.required, Validators.min(0), this.validateId()]],
label: [system?.label, Validators.required],
led: [system?.led],
order: [system?.order],
talkgroups: this.ngFormBuilder.array(system?.talkgroups?.map((talkgroup) => this.newTalkgroupForm(talkgroup)) || []),
units: this.ngFormBuilder.array(system?.units?.map((unit) => this.newUnitForm(unit)) || []),
});
}
newTalkgroupForm(talkgroup?: Talkgroup): FormGroup {
return this.ngFormBuilder.group({
frequency: [talkgroup?.frequency, Validators.min(0)],
groupId: [talkgroup?.groupId, [Validators.required, this.validateGroup()]],
id: [talkgroup?.id, [Validators.required, Validators.min(0), this.validateId()]],
label: [talkgroup?.label, Validators.required],
led: [talkgroup?.led],
name: [talkgroup?.name, Validators.required],
patches: [talkgroup?.patches, this.validatePatches()],
tagId: [talkgroup?.tagId, [Validators.required, this.validateTag()]],
});
}
newUnitForm(unit?: Unit): FormGroup {
return this.ngFormBuilder.group({
id: [unit?.id, [Validators.required, Validators.min(0), this.validateId()]],
label: [unit?.label, Validators.required],
});
}
newOptionsForm(options?: Options): FormGroup {
return this.ngFormBuilder.group({
autoPopulate: [options?.autoPopulate],
dimmerDelay: [options?.dimmerDelay, [Validators.required, Validators.min(0)]],
disableAudioConversion: [options?.disableAudioConversion],
disableDuplicateDetection: [options?.disableDuplicateDetection],
keypadBeeps: [options?.keypadBeeps, Validators.required],
pruneDays: [options?.pruneDays, [Validators.required, Validators.min(0)]],
sortTalkgroups: [options?.sortTalkgroups],
});
}
private configWebSocketClose(): void {
if (this.configWebSocket instanceof WebSocket) {
this.configWebSocket.onclose = null;
this.configWebSocket.onmessage = null;
this.configWebSocket.onopen = null;
this.configWebSocket.close();
this.configWebSocket = undefined;
};
}
private configWebSocketReconnect(): void {
this.configWebSocketClose();
this.configWebSocketOpen();
}
private configWebSocketOpen(): void {
if (!this.token) {
return;
}
const webSocketUrl = new URL(this.getUrl(url.config), window.location.href).href.replace(/^http/, 'ws');
this.configWebSocket = new WebSocket(webSocketUrl);
this.configWebSocket.onclose = (ev: CloseEvent) => {
if (ev.code === 1000) {
this.token = '';
this.event.emit({ authenticated: this.authenticated });
} else {
timer(2000).subscribe(() => this.configWebSocketReconnect());
}
};
this.configWebSocket.onopen = () => {
this.configWebSocket?.send(this.token);
if (this.configWebSocket instanceof WebSocket) {
this.configWebSocket.onmessage = (ev: MessageEvent<string>) => {
this.event.emit({ config: JSON.parse(ev.data) });
}
}
}
}
private errorHandler(error: HttpErrorResponse): void {
if (error.status === 401) {
this.token = '';
this.event.emit({ authenticated: this.authenticated });
this.configWebSocketClose();
} else {
this.matSnackBar.open(error.message, '', { duration: 5000 });
}
}
private getHeaders(): HttpHeaders {
return new HttpHeaders({
Authorization: this.token || '',
});
}
private getUrl(path: string): string {
return `${window.location.href}/../api/admin${path.charAt(0) === '/' ? path : `/${path}`}`;
}
private validateAccessCode(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
const access: Access[] = control.parent?.parent?.getRawValue() || [];
const count = access.reduce((c, a) => c += a.code === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateApiKey(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
const apiKeys: ApiKey[] = control.parent?.parent?.getRawValue() || [];
const count = apiKeys.reduce((c, a) => c += a.key === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateBlacklists(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length ? /^[0-9]+(,[0-9]+)*$/.test(control.value) ? null : { invalid: true } : null;
};
}
private validateDirectory(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
const dirWatch: DirWatch[] = control.parent?.parent?.getRawValue() || [];
const count = dirWatch.reduce((c, a) => c += a.directory === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateDownstreamUrl(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
const downstream: Downstream[] = control.parent?.parent?.getRawValue() || [];
const count = downstream.reduce((c, a) => c += a.url === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateDirwatchSystemId(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const dirwatch = control.parent?.getRawValue() || {};
const mask = dirwatch.mask || '';
const type = dirwatch.type;
return ['sdr-trunk'].includes(type) || control.value !== null || /#SYS/.test(mask) ? null : { required: true };
};
}
private validateDirwatchTalkgroupId(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const dirwatch = control.parent?.getRawValue() || {};
const mask = dirwatch.mask || '';
const type = dirwatch.type;
return ['trunk-recorder', 'sdr-trunk'].includes(type) || control.value !== null || /#TG/.test(mask) ? null : { required: true };
};
}
private validateExtension(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
return /^[0-9a-zA-Z]+$/.test(control.value) ? null : { invalid: true };
};
}
private validateGroup(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'number') {
return null;
}
const groupIds = control.root.get('groups')?.value.map((group: Group) => group._id);
return groupIds ? groupIds.includes(control.value) ? null : { required: true } : null;
};
}
private validateId(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === null || typeof control.value !== 'number') {
return null;
}
const systems: System[] = control.parent?.parent?.getRawValue() || [];
const count = systems.reduce((c, s) => c += s.id === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateMask(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string') {
return null;
}
const masks = ['#DATE', '#HZ', '#KHZ', '#MHZ', '#SYS', '#TIME', '#TG', '#UNIT', '#ZTIME'];
const metas = control.value.match(/(#[A-Z]+)/g) || [];
const count = metas.reduce((c, m) => {
if (masks.includes(m)) {
c++;
}
return c;
}, 0);
return count ? null : { invalid: true };
};
}
private validatePatches(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length ? /^[0-9]+(,[0-9]+)*$/.test(control.value) ? null : { invalid: true } : null;
};
}
private validateTag(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'number') {
return null;
}
const tagIds = control.root.get('tags')?.value.map((tag: Tag) => tag._id);
return tagIds ? tagIds.includes(control.value) ? null : { required: true } : null;
};
}
private validateUrl(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
return /^https?:\/\/.+$/.test(control.value) ? null : { invalid: true }
};
}
}

View file

@ -0,0 +1,95 @@
<div class="row top">
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button>
</div>
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }}
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="access">
<div class="row">
<p>
<span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span>
</p>
<mat-form-field floatLabel="never">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<mat-error *ngIf="access.get('code')?.hasError('duplicate')">
Code is already defined
</mat-error>
<mat-error *ngIf="access.get('code')?.hasError('required')">
Code is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="access.get('ident')?.hasError('required')">
Ident is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-datepicker #expirationDate></mat-datepicker>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Limit</span><br>
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="limit" placeholder="Limit">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
The access code allows access to <u>
<ng-container *ngIf="access.value.systems === '*'">all</ng-container>
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete access
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,87 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select.component';
@Component({
selector: 'rdio-scanner-admin-access',
templateUrl: './access.component.html',
})
export class RdioScannerAdminAccessComponent {
@Input() form: FormArray | undefined;
hideCode = true;
get accesses(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { }
add(): void {
const access = this.adminService.newAccessForm({ systems: '*' });
access.markAllAsTouched();
this.form?.insert(0, access);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
select(access: FormGroup): void {
const matDialogRef = this.matDialog.open(RdioScannerAdminSystemsSelectComponent, { data: access });
matDialogRef.afterClosed().subscribe((data) => {
if (data) {
access.get('systems')?.setValue(data);
access.markAsDirty();
}
});
}
}

View file

@ -0,0 +1,80 @@
<div class="row top">
<p class="mat-body">API keys are used for recorder upload scripts and downstream instances. Each of them must
authenticate with an API key to exchange audio files.</p>
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
</div>
<p *ngIf="!apiKeys.length" class="mat-small text-center">No defined API keys</p>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="apiKeys" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let apiKey of apiKeys; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ apiKey.value.ident || 'NewApiKey' }}
<mat-icon *ngIf="apiKey.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="apiKey">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the API key.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Key</span><br>
<span class="mat-caption">Api key.</span>
</p>
<mat-form-field floatLabel="never">
<input #key type="text" matInput formControlName="key" placeholder="Key">
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon>
</button>
<mat-error *ngIf="apiKey.get('key')?.hasError('duplicate')">
Key is already defined
</mat-error>
<mat-error *ngIf="apiKey.get('key')?.hasError('required')">
Key is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for
whom.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="apiKey.get('ident')?.hasError('required')">
Ident is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This API key allows access to <u>
<ng-container *ngIf="apiKey.value.systems === '*'">all</ng-container>
<ng-container *ngIf="apiKey.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="apiKey.disabled" (click)="select(apiKey)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete API key
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,108 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select.component';
@Component({
selector: 'rdio-scanner-admin-api-keys',
templateUrl: './api-keys.component.html',
})
export class RdioScannerAdminApiKeysComponent {
@Input() form: FormArray | undefined;
get apiKeys(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { }
add(): void {
const apiKey = this.adminService.newApiKeyForm({
key: this.uuid(),
systems: '*',
});
apiKey.markAllAsTouched();
this.form?.insert(0, apiKey);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
copy(inputElement: HTMLInputElement): void {
inputElement.select();
document.execCommand('copy');
inputElement.setSelectionRange(0, 0);
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
select(access: FormGroup): void {
const matDialogRef = this.matDialog.open(RdioScannerAdminSystemsSelectComponent, { data: access });
matDialogRef.afterClosed().subscribe((data) => {
if (data) {
access.get('systems')?.setValue(data);
access.markAsDirty();
}
});
}
private uuid() {
let dt = new Date().getTime();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
}

View file

@ -0,0 +1,90 @@
<form *ngIf="form" autocomplete="off" [formGroup]="form" (ngSubmit)="save()">
<mat-accordion displayMode="flat">
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>manage_accounts</mat-icon>
Access
<mat-icon *ngIf="form?.get('access')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="apiKeyComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>vpn_key</mat-icon>
ApiKey
<mat-icon *ngIf="form?.get('apiKey')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-api-keys #apiKeyComponent [form]="apiKeys"></rdio-scanner-admin-api-keys>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="dirWatchComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>folder</mat-icon>
Dirwatch
<mat-icon *ngIf="form?.get('dirWatch')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-dir-watch #dirWatchComponent [docker]="docker" [form]="dirWatch"></rdio-scanner-admin-dir-watch>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>share</mat-icon>
Downstreams
<mat-icon *ngIf="form?.get('downstreams')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>workspaces</mat-icon>
Groups
<mat-icon *ngIf="form?.get('groups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>tune</mat-icon>
Options
<mat-icon *ngIf="form?.get('options')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>podcasts</mat-icon>
Systems
<mat-icon *ngIf="form?.get('systems')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>sell</mat-icon>
Tags
<mat-icon *ngIf="form?.get('tags')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="form?.disabled || form?.pristine"
(click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary"
[disabled]="form?.disabled || form?.pristine || !form?.valid">Save</button>
</div>
</form>

View file

@ -0,0 +1,74 @@
rdio-scanner-admin-config {
.row {
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
&.bottom {
margin-top: 1rem;
}
&.top {
margin-bottom: 1rem;
}
> p {
box-sizing: border-box;
flex: 1;
padding-right: 1rem;
}
ul {
list-style-type: none;
padding-left: 1rem;
}
.mat-button:last-child {
margin-left: auto;
}
}
.cdk-drag-handle {
opacity: 0.5;
}
.mat-expansion-panel-header-title {
align-items: center;
}
.mat-icon[color="warn"] {
font-size: 1rem;
height: 1rem;
opacity: 0.9;
padding-left: 0.5rem;
width: 1rem;
}
.text-center {
text-align: center;
}
}
@media (max-width: 719px) {
rdio-scanner-admin-config .mat-form-field {
flex: 100%;
}
}
@media (min-width: 720px) {
.row {
> p ~ div,
.mat-form-field {
min-width: 260px;
}
> p ~ div > {
.mat-button,
.mat-slide-toggle {
float: right;
}
}
}
}

View file

@ -0,0 +1,159 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, QueryList, ViewChildren, ViewEncapsulation } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
selector: 'rdio-scanner-admin-config',
styleUrls: ['./config.component.scss'],
templateUrl: './config.component.html',
})
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
form: FormGroup | undefined;
get access(): FormArray {
return this.form?.get('access') as FormArray;
}
get apiKeys(): FormArray {
return this.form?.get('apiKeys') as FormArray;
}
get dirWatch(): FormArray {
return this.form?.get('dirWatch') as FormArray;
}
get docker(): boolean {
return this.config?.docker || false;
}
get downstreams(): FormArray {
return this.form?.get('downstreams') as FormArray;
}
get groups(): FormArray {
return this.form?.get('groups') as FormArray;
}
get options(): FormGroup {
return this.form?.get('options') as FormGroup;
}
get systems(): FormArray {
return this.form?.get('systems') as FormArray;
}
get tags(): FormArray {
return this.form?.get('tags') as FormArray;
}
private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event && event.authenticated === true) {
this.config = await this.adminService.getConfig();
this.reset();
}
if ('config' in event) {
this.config = event.config;
if (this.form?.pristine) {
this.reset();
}
}
});
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(
private adminService: RdioScannerAdminService,
private ngChangeDetectorRef: ChangeDetectorRef,
) { }
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
}
async ngOnInit(): Promise<void> {
this.config = await this.adminService.getConfig();
this.reset();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
reset(config = this.config, options?: { dirty?: boolean }): void {
this.form = this.adminService.newConfigForm(config);
this.form.statusChanges.subscribe(() => this.ngChangeDetectorRef.markForCheck());
if (options?.dirty === true) {
this.form.markAsDirty();
}
this.groups.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => {
const groupId = talkgroup.get('groupId') as FormControl;
groupId.updateValueAndValidity();
if (groupId.errors) {
groupId.markAsTouched();
}
});
});
});
this.tags.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => {
const tagId = talkgroup.get('tagId') as FormControl;
tagId.updateValueAndValidity();
if (tagId.errors) {
tagId.markAsTouched();
}
});
});
});
this.ngChangeDetectorRef.markForCheck();
}
async save(): Promise<void> {
this.form?.markAsPristine();
await this.adminService.saveConfig(this.form?.getRawValue());
}
}

View file

@ -0,0 +1,189 @@
<div class="row top">
<p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p>
<button type="button" mat-button color="accent" (click)="add()">New dir watch</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="dirWatches" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let dirWatch of dirWatches; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ dirWatch.value.directory || 'NewDirWatch' }}
<mat-icon *ngIf="dirWatch.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="dirWatch">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the dirwatch.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Delete After</span><br>
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
audio files will be ingested and deleted as soon as the server starts. If not activated,
pre-existing audio files are neither ingested nor deleted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Directory</span><br>
<span class="mat-caption">
<ng-container *ngIf="docker">
Absolute or relatives inside the <u>Docker container</u>, path of the directory to be
monitored.
</ng-container>
<ng-container *ngIf="!docker">
Absolute or relatives to the server, path of the directory to be monitored.
</ng-container>
</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="directory" placeholder="Directory">
<mat-error *ngIf="dirWatch.get('directory')?.hasError('required')">
Directory is required
</mat-error>
<mat-error *ngIf="dirWatch.get('directory')?.hasError('duplicate')">
Directory is already defined
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
"wav".</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="extension" placeholder="Extension">
<mat-error *ngIf="dirWatch.get('extension')?.hasError('invalid')">
Invalid extension
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">When SDR Trunk, metadata are obtained from the MP3 tags. Note that
the label of the SDR Trunk system must match the label of the Rdio Scanner system label. When
trunk-recorder, metadata are obtained from the JSON file.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="type" placeholder="Type">
<mat-option [value]="null">Default</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select #sysId formControlName="systemId" placeholder="System">
<mat-option [value]="null">Metatag #SYS</mat-option>
<mat-option *ngFor="let system of systems" [value]="system.value.id">
{{ system.value.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="dirWatch.get('systemId')?.hasError('required')">
System is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<mat-option [value]="null">Metatag #TG</mat-option>
<mat-option *ngFor="let talkgroup of talkgroups[dirWatch.value.systemId] || []"
[value]="talkgroup.value.id">
{{ talkgroup.value.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="dirWatch.get('talkgroupId')?.hasError('required')">
Talkgroup is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Mask</span><br>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
specific META tags. Possible META tags:
<ul>
<li><b>#DATE</b> - extract the date like 20201231 (YYYYMMMDD) or 2020-12-31 (YYYY-MM-DD).
</li>
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
<li><b>#SYS</b> - extract the system id like 11.</li>
<li><b>#TIME</b> - extract the local time like 0853439 or 08:34:39.</li>
<li><b>#TG</b> - extract the talkgroup id like 54241.</li>
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
<li><b>#ZTIME</b> - extract the zulu time like 0453439 or 04:34:39.</li>
</ul>
Example: cymx_#TG_#DATE_#TIME_#HZ
</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="mask" placeholder="Mask">
<mat-error *ngIf="dirWatch.get('mask')?.hasError('invalid')">
Invalid mask
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="dirWatch.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the
recorder has created the file. You can set a timeout value in milliseconds for the audio file to
settle before ingesting it.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="delay" placeholder="Delay">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Use Polling</span><br>
<span class="mat-caption">Use polling when monitoring the directory. While this takes a bit more CPU
resources, it is necessary if the monitored directory is from a remote host or from outside of a
Docker container.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="usePolling"></mat-slide-toggle>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete dirwatch
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,116 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, OnChanges, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-dir-watch',
templateUrl: './dir-watch.component.html',
})
export class RdioScannerAdminDirWatchComponent implements OnChanges {
@Input() docker: boolean | undefined;
@Input() form: FormArray | undefined;
get dirWatches(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
get systems(): FormGroup[] {
const systems = this.form?.root.get('systems') as FormArray;
return systems.controls as FormGroup[];
}
get talkgroups(): FormGroup[][] {
return this.systems.reduce((talkgroups, system) => {
const faTalkgroups = system.get('talkgroups') as FormArray;
talkgroups[system.value.id] = faTalkgroups.controls as FormGroup[];
return talkgroups;
}, [] as FormGroup[][]);
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
ngOnChanges(): void {
if (this.form) {
this.dirWatches.flatMap((control) => this.registerOnChanges(control));
}
}
add(): void {
const dirWatch = this.adminService.newDirWatchForm({ usePolling: this.docker });
dirWatch.markAllAsTouched();
this.registerOnChanges(dirWatch);
this.form?.insert(0, dirWatch);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
private registerOnChanges(control: FormGroup): void {
const mask = control.get('mask') as FormControl;
const type = control.get('type') as FormControl;
mask.valueChanges.subscribe(() => this.validateIds(control));
type.valueChanges.subscribe(() => this.validateIds(control));
}
private validateIds(control: FormGroup): void {
const systemId = control.get('systemId');
const talkgroupId = control.get('talkgroupId');
systemId?.updateValueAndValidity();
systemId?.markAsTouched();
talkgroupId?.updateValueAndValidity();
talkgroupId?.markAsTouched();
}
}

View file

@ -0,0 +1,81 @@
<div class="row top">
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
<button type="button" mat-button color="accent" (click)="add()">New downstream</button>
</div>
<p *ngIf="!downstreams.length" class="mat-small text-center">No defined downstreams</p>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }}
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="downstream">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the downstream.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span>
</p>
<mat-form-field floatLabel="never">
<input #key type="text" matInput formControlName="apiKey" placeholder="API key">
<mat-error *ngIf="downstream.get('apiKey')?.hasError('duplicate')">
API key is already defined
</mat-error>
<mat-error *ngIf="downstream.get('apiKey')?.hasError('required')">
API key is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="url" placeholder="URL">
<mat-error *ngIf="downstream.get('url')?.hasError('required')">
URL is required
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')">
URL is invalid
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')">
URL is already defined
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This downstream allows access to <u>
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container>
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete downstream
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,85 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select.component';
@Component({
selector: 'rdio-scanner-admin-downstreams',
templateUrl: './downstreams.component.html',
})
export class RdioScannerAdminDownstreamsComponent {
@Input() form: FormArray | undefined;
get downstreams(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { }
add(): void {
const downstream = this.adminService.newDownstreamForm({ systems: '*' });
downstream.markAllAsTouched();
this.form?.insert(0, downstream);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
select(access: FormGroup): void {
const matDialogRef = this.matDialog.open(RdioScannerAdminSystemsSelectComponent, { data: access });
matDialogRef.afterClosed().subscribe((data) => {
if (data) {
access.get('systems')?.setValue(data);
access.markAsDirty();
}
});
}
}

View file

@ -0,0 +1,18 @@
<div class="row top">
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New group</button>
</div>
<div class="groups">
<div *ngFor="let group of groups; index as i" [formGroup]="group">
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
<mat-icon>clear</mat-icon>
</button>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Group">
<mat-error *ngIf="groups[i].get('label')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
</div>
</div>

View file

@ -0,0 +1,19 @@
.groups {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
> div {
align-items: center;
display: flex;
flex-direction: row;
margin-right: 0.5rem;
}
}
@media (max-width: 719px) {
.groups > div {
flex: 100%;
}
}

View file

@ -0,0 +1,56 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-groups',
styleUrls: ['./groups.component.scss'],
templateUrl: './groups.component.html',
})
export class RdioScannerAdminGroupsComponent {
@Input() form: FormArray | undefined;
get groups(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
}
constructor(private adminService: RdioScannerAdminService) { }
add(): void {
const id = this.groups.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
const group = this.adminService.newGroupForm({ _id: id });
group.markAllAsTouched();
this.form?.insert(0, group);
this.form?.markAsDirty();
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
}

View file

@ -0,0 +1,84 @@
<ng-container *ngIf="form" [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and
talkgroups.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span>
</p>
<mat-form-field>
<input matInput formControlName="dimmerDelay" type="number">
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required
</mat-error>
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Disable Audio Conversion</span><br>
<span class="mat-caption">Disable the audio format conversion to m4a/aac. This is usefull if another Rdio
Scanner instance is feeding this instance where audio has already been converted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableAudioConversion"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Prune Days</span><br>
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
disable.</span>
</p>
<mat-form-field>
<input matInput formControlName="pruneDays" type="number">
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')">
Prune days is required
</mat-error>
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')">
Prune days is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,29 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-options',
templateUrl: './options.component.html',
})
export class RdioScannerAdminOptionsComponent {
@Input() form: FormGroup | undefined;
}

View file

@ -0,0 +1,50 @@
<div>
<div mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything">
Everything
</mat-checkbox>
<h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups">
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }}
</mat-checkbox>
</div>
</div>
<h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags">
<div *ngFor="let tag of configTags; index as tagIndex" [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }}
</mat-checkbox>
</div>
</div>
<h3 class="mat-caption">Systems</h3>
<div formArrayName="systems">
<div *ngFor="let system of configSystems; index as systemIndex" [formGroupName]="systemIndex">
<div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]">
{{ system.value.label }}
</mat-checkbox>
</div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
<div *ngFor="let talkgroup of configTalkgroups[systemIndex]; index as talkgroupIndex"
[formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }}
</mat-checkbox>
</div>
</div>
</div>
</div>
</div>
<div mat-dialog-actions>
<button mat-raised-button (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>
</div>

View file

@ -0,0 +1,64 @@
rdio-scanner-admin-systems-selection {
> div {
min-width: 600px;
}
.flex {
flex: 1;
}
.flex-row {
align-items: center;
display: flex;
flex-direction: row;
&.wrap {
flex-wrap: wrap;
> * {
box-sizing: border-box;
overflow: hidden;
padding: 0 0.125rem;
&:last-child {
margin-right: auto;
}
}
}
}
.indent {
margin-left: 24px;
}
.margin {
margin-bottom: 1rem;
}
.mat-checkbox,
.mat-checkbox-layout {
max-width: 100%;
}
.mat-checkbox-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mat-dialog-actions {
justify-content: space-between;
}
@media (max-width: 719px) {
.flex-row.wrap > * {
flex: 0 0 50%;
}
}
@media (min-width: 720px) {
.flex-row.wrap > * {
flex: 0 0 20%;
}
}
}

View file

@ -0,0 +1,349 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { ChangeDetectorRef, Component, Inject, ViewEncapsulation } from '@angular/core';
import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Access } from '../../../admin.service';
interface System {
all: boolean;
id: number;
talkgroups: Talkgroup[];
}
interface Talkgroup {
checked: boolean;
id: number;
}
@Component({
encapsulation: ViewEncapsulation.None,
selector: 'rdio-scanner-admin-systems-selection',
styleUrls: ['./select.component.scss'],
templateUrl: './select.component.html',
})
export class RdioScannerAdminSystemsSelectComponent {
indeterminate = {
everything: false,
groups: [] as boolean[],
systems: [] as boolean[],
tags: [] as boolean[],
};
select = this.ngFormBuilder.group({
all: this.ngFormBuilder.control(false),
groups: this.ngFormBuilder.array([]),
tags: this.ngFormBuilder.array([]),
systems: this.ngFormBuilder.array([]),
});
configTalkgroups = this.configSystems.map((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[];
});
get configGroups(): FormGroup[] {
const faGroups = this.access.root.get('groups') as FormArray;
return faGroups.controls as FormGroup[];
}
get configSystems(): FormGroup[] {
const faSystems = this.access.root.get('systems') as FormArray;
return faSystems.controls as FormGroup[];
}
get configTags(): FormGroup[] {
const faTags = this.access.root.get('tags') as FormArray;
return faTags.controls as FormGroup[];
}
constructor(
@Inject(MAT_DIALOG_DATA) public access: FormGroup,
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) {
const fcAll = this.select.get('all') as FormControl;
const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
const faTags = this.select.get('tags') as FormArray;
this.configGroups.forEach((configGroup) => {
const fgGroup = this.ngFormBuilder.group({
_id: [configGroup.value._id],
checked: [false],
});
faGroups.push(fgGroup);
fgGroup.valueChanges.subscribe((vGroup) => {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.groupId === vGroup._id && fgTalkgroup.value.checked !== vGroup.checked) {
fgTalkgroup.get('checked')?.setValue(vGroup.checked);
}
});
});
});
});
this.configSystems.forEach((configSystem, index) => {
const fcSystemAll = this.ngFormBuilder.control(false);
const faSystemTalkgroups = this.ngFormBuilder.array([]);
const fgSystem = this.ngFormBuilder.group({
all: fcSystemAll,
id: configSystem.value.id,
talkgroups: faSystemTalkgroups
});
this.configTalkgroups[index].forEach((configTalkgroup) => {
const fgSystemTalkgroup = this.ngFormBuilder.group({
checked: [false],
groupId: configTalkgroup.value.groupId,
id: configTalkgroup.value.id,
tagId: configTalkgroup.value.tagId
});
faSystemTalkgroups.push(fgSystemTalkgroup);
fgSystemTalkgroup.valueChanges.subscribe(() => {
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.value.checked);
fcSystemAll.setValue(vAll, { emitEvent: false });
});
});
faSystems.push(fgSystem);
fgSystem.valueChanges.subscribe(() => {
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
fcSystemAll.valueChanges.subscribe((vAll) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll));
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let off = 0;
let on = 0;
vSystemTalkgroups.forEach((vSystemTalkgroup: Talkgroup) => {
if (vSystemTalkgroup.checked) {
on++;
} else {
off++;
}
});
this.indeterminate.systems[index] = !!off && !!on;
faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false });
});
});
this.configTags.forEach((configTag) => {
const fgTag = this.ngFormBuilder.group({
_id: [configTag.value._id],
checked: [false],
});
faTags.push(fgTag);
fgTag.valueChanges.subscribe((vTag) => {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === vTag._id && fgTalkgroup.value.checked !== vTag.checked) {
fgTalkgroup.get('checked')?.setValue(vTag.checked);
}
});
});
});
});
fcAll.valueChanges.subscribe((vAll) => {
faSystems.controls.flatMap((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls;
}).concat(faGroups.controls, faTags.controls).forEach((control) => {
control.get('checked')?.setValue(vAll);
});
});
faSystems.valueChanges.subscribe((vSystems) => {
let off = 0;
let on = 0;
vSystems.forEach((vSystem: System) => {
if (vSystem.all) {
on++;
} else {
off++;
}
});
this.indeterminate.everything = !!off && !!on;
fcAll.setValue(!off && on, { emitEvent: false });
});
const vAccess: Access = this.access.value;
if (vAccess.systems === '*') {
this.select.get('all')?.setValue(true);
} else if (Array.isArray(vAccess.systems)) {
vAccess.systems.forEach((vSystem: { id: number; talkgroups: { id: number }[] | number[] | '*' } | number) => {
if (typeof vSystem === 'number') {
faSystems.controls.find((fgSystem) => fgSystem.value.id === vSystem)?.get('all')?.setValue(true);
} else if (vSystem !== null && typeof vSystem === 'object') {
const fgSystem = faSystems.controls.find((fg) => fg.value.id === vSystem.id);
if (fgSystem) {
if (vSystem.talkgroups === '*') {
fgSystem.get('all')?.setValue(true);
} else if (Array.isArray(vSystem.talkgroups)) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.value.id === talkgroupId);
fgTalkgroup?.get('checked')?.setValue(true);
});
fgSystem?.updateValueAndValidity();
}
}
}
});
}
}
accept(): void {
const select = this.select.value;
const access: Access['systems'] = select.all ? '*' : select.systems.filter((system: System) => {
return system.all || system.talkgroups.some((talkgroup: Talkgroup) => talkgroup.checked);
}).map((system: System) => {
if (system.all) {
return {
id: system.id,
talkgroups: '*',
};
} else {
return {
id: system.id,
talkgroups: system.talkgroups
.filter((talkgroup: Talkgroup) => talkgroup.checked)
.map((talkgroup: Talkgroup) => talkgroup.id),
};
}
});
this.matDialogRef.close(access);
}
cancel(): void {
this.matDialogRef.close(null);
}
private rebuildGroupIndeterminates(): void {
const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faGroups.controls.forEach((fgGroup, index) => {
let off = 0;
let on = 0;
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.groupId === fgGroup.value._id) {
if (fgTalkgroup.value.checked) {
on++;
} else {
off++;
}
}
});
});
this.indeterminate.groups[index] = !!off && !!on;
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
});
}
private rebuildTagIndeterminates(): void {
const faTags = this.select.get('tags') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faTags.controls.forEach((fgTag, index) => {
let off = 0;
let on = 0;
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === fgTag.value._id) {
if (fgTalkgroup.value.checked) {
on++;
} else {
off++;
}
}
});
});
this.indeterminate.tags[index] = !!off && !!on;
fgTag.get('checked')?.setValue(!off && on, { emitEvent: false });
});
}
}

View file

@ -0,0 +1,142 @@
<ng-container [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this system. If not
specified, the indicator will light green.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="null">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<div class="row">
<p>
<span class="mat-body">Blacklists</span><br>
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
has an effect if the auto populate option is enabled.</span>
</p>
<mat-form-field floatLabel="never">
<textarea type="text" matInput [matTextareaAutosize]="true" formControlName="blacklists"
placeholder="Blacklists"></textarea>
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Talkgroups
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="talkgroups"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; index as i" [formGroup]="talkgroup"
cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
(remove)="removeTalkgroup(i)">
</rdio-scanner-admin-talkgroup>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Units
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
screen.<br>You can drag and drop them to rearrange</p>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="units"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let unit of units; index as i" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
</rdio-scanner-admin-unit>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete system
</button>
</div>
</ng-container>

View file

@ -0,0 +1,118 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, EventEmitter, Input, Output, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({
selector: 'rdio-scanner-admin-system',
templateUrl: './system.component.html',
})
export class RdioScannerAdminSystemComponent {
@Input() form = new FormGroup({});
@Input() groups: Group[] = [];
@Input() tags: Tag[] = [];
@Output() add = new EventEmitter<void>();
@Output() remove = new EventEmitter<void>();
leds = this.adminService.getLeds();
get talkgroups(): FormGroup[] {
const talkgroups = this.form.get('talkgroups') as FormArray;
return talkgroups.controls as FormGroup[];
}
get units(): FormGroup[] {
const units = this.form.get('units') as FormArray;
return units.controls as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
addTalkgroup(): void {
const talkgroups = this.form.get('talkgroups') as FormArray;
talkgroups.insert(0, this.adminService.newTalkgroupForm());
this.form.markAsDirty();
}
addUnit(): void {
const units = this.form.get('units') as FormArray;
units.insert(0, this.adminService.newUnitForm());
this.form.markAsDirty();
}
blacklistTalkgroup(index: number): void {
const talkgroup = this.talkgroups[index];
const id = talkgroup.value.id;
if (typeof id !== 'number') {
return;
}
const blacklists = this.form?.get('blacklists') as FormControl;
blacklists.setValue(blacklists.value?.trim() ? `${blacklists.value},${id}` : `${id}`);
this.removeTalkgroup(index);
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
this.form.markAsDirty();
}
}
removeTalkgroup(index: number): void {
const talkgroups = this.form.get('talkgroups') as FormArray;
talkgroups.removeAt(index);
talkgroups.markAsDirty();
}
removeUnit(index: number): void {
const units = this.form.get('units') as FormArray;
units.removeAt(index);
units.markAsDirty();
}
}

View file

@ -0,0 +1,20 @@
<div class="row top">
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
systems to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,71 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService, System } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-systems',
templateUrl: './systems.component.html',
})
export class RdioScannerAdminSystemsComponent {
@Input() form: FormArray | undefined;
get systems(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
add(): void {
const system = this.adminService.newSystemForm();
system.markAllAsTouched();
this.form?.insert(0, system);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
}

View file

@ -0,0 +1,123 @@
<ng-container *ngIf="form" [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form?.get('name')?.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Group</span><br>
<span class="mat-caption">Group to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="groupId" placeholder="Group">
<mat-option *ngFor="let group of groups" [value]="group._id">
{{ group.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('groupId')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag._id">
{{ tag.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('tagId')?.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this talkgroup. If not specified,
the color configured for the system is used.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="null">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form?.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Patches</span><br>
<span class="mat-caption">A comma separated list of talkgroup id to patch in this one.</span>
</p>
<mat-form-field floatLabel="never">
<textarea type="text" matInput [matTextareaAutosize]="true" formControlName="patches" placeholder="Patches"></textarea>
<mat-error *ngIf="form?.get('patches')?.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<div class="row bottom">
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup
</button>
</div>
</ng-container>

View file

@ -0,0 +1,52 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({
selector: 'rdio-scanner-admin-talkgroup',
templateUrl: './talkgroup.component.html',
})
export class RdioScannerAdminTalkgroupComponent {
@Input() form: FormGroup | undefined;
@Output() blacklist = new EventEmitter<void>();
@Output() remove = new EventEmitter<void>();
leds = this.adminService.getLeds();
get groups(): Group[] {
return this.form?.root.get('groups')?.value as Group[];
}
get patches(): FormControl[] {
const patches = this.form?.get('patches') as FormArray;
return patches.controls as FormControl[];
}
get tags(): Tag[] {
return this.form?.root.get('tags')?.value as Tag[];
}
constructor(private adminService: RdioScannerAdminService) { }
}

View file

@ -0,0 +1,37 @@
<ng-container *ngIf="form" [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Unit identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete unit
</button>
</div>
</ng-container>

View file

@ -0,0 +1,31 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-unit',
templateUrl: './unit.component.html',
})
export class RdioScannerAdminUnitComponent {
@Input() form: FormGroup | undefined;
@Output() remove = new EventEmitter<void>();
}

View file

@ -0,0 +1,18 @@
<div class="row top">
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
based on their tag.</p>
<button type="button" mat-button color="accent" (click)="add()">New tag</button>
</div>
<div class="tags">
<div *ngFor="let tag of tags; index as i" [formGroup]="tag">
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
<mat-icon>clear</mat-icon>
</button>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Tag">
<mat-error *ngIf="tags[i].get('label')?.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
</div>
</div>

View file

@ -0,0 +1,19 @@
.tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
> div {
align-items: center;
display: flex;
flex-direction: row;
margin-right: 0.5rem;
}
}
@media (max-width: 719px) {
.tags > div {
flex: 100%;
}
}

View file

@ -0,0 +1,56 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-tags',
styleUrls: ['./tags.component.scss'],
templateUrl: './tags.component.html',
})
export class RdioScannerAdminTagsComponent {
@Input() form: FormArray | undefined;
get tags(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
}
constructor(private adminService: RdioScannerAdminService) { }
add(): void {
const id = this.tags.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
const tag = this.adminService.newTagForm({ _id: id });
tag.markAllAsTouched();
this.form?.insert(0, tag);
this.form?.markAsDirty();
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
}

View file

@ -0,0 +1,20 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
export * from './admin.module';

View file

@ -0,0 +1,9 @@
<form [formGroup]="form" (ngSubmit)="login()">
<p class="mat-body-1">Please enter the admin password to gain access to the administrative dashboard</p>
<mat-form-field hideRequiredMarker>
<mat-label>Password</mat-label>
<input matInput formControlName="password" type="password" required>
<mat-error>{{ message }}</mat-error>
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="form.disabled || form.invalid">Login</button>
</form>

View file

@ -0,0 +1,8 @@
form > button {
margin-top: 1rem;
}
.mat-raised-button,
.mat-form-field {
display: block;
}

View file

@ -0,0 +1,62 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { RdioScannerAdminService } from '../admin.service';
@Component({
selector: 'rdio-scanner-admin-login',
styleUrls: ['./login.component.scss'],
templateUrl: './login.component.html',
})
export class RdioScannerAdminLoginComponent {
@Output() loggedIn = new EventEmitter<void>();
form = this.formBuilder.group({
password: [null, Validators.required],
});
message = '';
constructor(
private adminService: RdioScannerAdminService,
private formBuilder: FormBuilder,
) { }
async login(password = this.form.get('password')?.value): Promise<void> {
if (!password) {
return;
}
this.form.disable();
const loggedIn = await this.adminService.login(password);
if (loggedIn) {
this.loggedIn.emit();
} else {
this.form.enable();
this.form.reset();
this.message = 'Invalid password';
}
}
}

View file

@ -0,0 +1,88 @@
<mat-table [dataSource]="logs">
<ng-container matColumnDef="level">
<mat-header-cell *matHeaderCellDef>
<span>Level</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<mat-icon *ngIf="log?.level === 'error'" class="error">error</mat-icon>
<mat-icon *ngIf="log?.level === 'info'" class="info">notifications</mat-icon>
<mat-icon *ngIf="log?.level === 'warn'" class="warn">warning</mat-icon>
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef>
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="message">
<mat-header-cell *matHeaderCellDef>
<span>Message</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.message }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="logsQueryPending ? 'query' : 'determinate'">
</mat-progress-bar>
<mat-paginator [disabled]="logsQueryPending" [length]="logsQuery?.count" [hidePageSize]="true"
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
</mat-paginator>
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input formControlName="date" [matDatepicker]="date" matInput [max]="logsQuery?.dateStop"
[min]="logsQuery?.dateStart" placeholder="Choose a date" readonly (click)="date.open()"
(dateChange)="formHandler()">
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
<mat-datepicker #date></mat-datepicker>
</mat-form-field>
<mat-form-field>
<mat-label>
Level
</mat-label>
<mat-select formControlName="level" (selectionChange)="formHandler()">
<mat-option value="info">Informative</mat-option>
<mat-option value="warn">Warning</mat-option>
<mat-option value="error">Error</mat-option>
</mat-select>
</mat-form-field>
<div class="reset">
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
Reload
</button>
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
Reset
</button>
</div>
</form>

View file

@ -0,0 +1,56 @@
.error {
color: red;
}
.info {
color: green;
}
.warn {
color: orange;
}
.mat-cell,
.mat-header-cell {
&:nth-child(1),
&:nth-child(2),
&:nth-child(3) {
flex: 0 0 60px;
}
}
form {
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
.mat-form-field,
.reset {
box-sizing: border-box;
padding: 0 0.5rem 1rem 0.5rem;
}
.mat-form-field {
min-width: 220px;
}
.reset {
display: flex;
flex: 1;
flex-direction: row;
justify-content: space-between;
}
}
@media (max-width: 719px) {
form > .mat-form-field {
flex: 100%;
}
}
@media (min-width: 720px) {
form > .mat-form-field {
flex: 33%;
}
}

View file

@ -0,0 +1,124 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs';
import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../admin.service';
@Component({
selector: 'rdio-scanner-admin-logs',
styleUrls: ['./logs.component.scss'],
templateUrl: './logs.component.html',
})
export class RdioScannerAdminLogsComponent {
form = this.ngFormBuilder.group({
date: [null],
level: [null],
sort: [-1],
});
logs = new BehaviorSubject(new Array<Log | null>(10));
logsQuery: LogsQuery | null = null;
logsQueryPending = false;
private limit = 200;
private offset = 0;
@ViewChild(MatPaginator) private paginator: MatPaginator | undefined;
constructor(private adminService: RdioScannerAdminService, private ngFormBuilder: FormBuilder) { }
formHandler(): void {
this.paginator?.firstPage();
this.reload();
}
reset(): void {
this.form.reset({
date: null,
level: null,
sort: -1,
});
this.formHandler();
}
refresh(): void {
if (!this.paginator) {
return;
}
const from = this.paginator.pageIndex * this.paginator.pageSize;
const to = this.paginator.pageIndex * this.paginator.pageSize + this.paginator.pageSize - 1;
if (!this.logsQueryPending && (from >= this.offset + this.limit || from < this.offset)) {
this.reload();
} else if (this.logsQuery) {
const logs: Array<Log | null> = this.logsQuery.logs.slice(from % this.limit, to % this.limit + 1);
while (logs.length < this.logs.value.length) {
logs.push(null);
}
this.logs.next(logs);
}
}
async reload(): Promise<void> {
const pageIndex = this.paginator?.pageIndex || 0;
const pageSize = this.paginator?.pageSize || 0;
this.offset = Math.floor((pageIndex * pageSize) / this.limit) * this.limit;
const options: LogsQueryOptions = {
limit: this.limit,
offset: this.offset,
sort: this.form.value.sort,
};
if (typeof this.form.value.level === 'string') {
options.level = this.form.value.level;
}
if (this.form.value.date instanceof Date) {
options.date = this.form.value.date;
}
this.logsQueryPending = true;
this.form.disable();
this.logsQuery = await this.adminService.getLogs(options);
this.form.enable();
this.logsQueryPending = false;
this.refresh();
}
}

View file

@ -0,0 +1,12 @@
<mat-card *ngIf="todos.length">
<mat-card-title>Todos</mat-card-title>
<mat-list>
<mat-list-item *ngFor="let todo of todos">
<mat-icon *ngIf="todo.level === 'info'" class="info" mat-list-icon>notifications</mat-icon>
<mat-icon *ngIf="todo.level === 'warn'" class="warn" mat-list-icon>warning</mat-icon>
<div mat-line>
<span class="todo">{{ todo.message }}</span>
</div>
</mat-list-item>
</mat-list>
</mat-card>

View file

@ -0,0 +1,12 @@
.info {
color: green;
}
.warn {
color: orange;
}
.todo {
text-overflow: unset;
white-space: normal;
}

View file

@ -0,0 +1,92 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AdminEvent, Config, RdioScannerAdminService } from '../admin.service';
interface Todo {
level: 'info' | 'warn';
message: String;
}
@Component({
selector: 'rdio-scanner-admin-todos',
styleUrls: ['./todos.component.scss'],
templateUrl: './todos.component.html',
})
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
todos: Todo[] = [];
private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('config' in event) {
this.config = event.config;
}
if ('passwordNeedChange' in event) {
this.passwordNeedChange = event.passwordNeedChange || false;
}
this.rebuildTodos();
});
private passwordNeedChange = this.adminService.passwordNeedChange;
constructor(private adminService: RdioScannerAdminService) { }
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
}
ngOnInit(): void {
this.adminService.getConfig().then((config) => {
this.config = config;
this.rebuildTodos();
});
}
private rebuildTodos(): void {
const todos: Todo[] = [];
if (this.passwordNeedChange) {
todos.push({
level: 'warn',
message: 'You are using the default admin password, please change it from the tools / admin password menu.'
});
}
if (!this.config?.systems?.length) {
todos.push({
level: 'info',
message: 'No systems defined. You can define one from the systems menu, or import one from a CSV file from the tools menu, or turn on the global auto populate option from the options menu.',
});
}
if (!this.config?.apiKeys?.length && !this.config?.dirWatch?.length) {
todos.push({
level: 'info',
message: 'No apikeys and no dirwatch defined. Please set at least one to allow ingesting audio files.',
});
}
this.todos = todos;
}
}

View file

@ -0,0 +1,90 @@
<section>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
<ng-container *ngIf="csv.length === 0">
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
</ng-container>
<ng-container *ngIf="csv.length !== 0">
<button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container>
</p>
</section>
<section>
<p class="mat-body">step 2: select CSV style</p>
<p class="text-center">
<mat-radio-group [(ngModel)]="mode">
<mat-radio-button [value]="0">Trunk Recorder</mat-radio-button>
<mat-radio-button [value]="1">Radio Reference</mat-radio-button>
</mat-radio-group>
</p>
</section>
<section>
<p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll">
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][0]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][1]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>
<span>Description</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][2]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef>
<span>Tag</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][3]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef>
<span>Group</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][4]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
</div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p>
</section>
<section>
<p class="mat-body">step 4: import to configuration</p>
<p class="text-center">
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration
</button>
</p>
</section>

View file

@ -0,0 +1,31 @@
.mat-radio-group {
display: flex;
flex-direction: row;
justify-content: center;
> *:not(:last-child) {
margin-right: 2rem;
}
}
.mat-table {
min-width: 720px;
}
.mat-cell >  span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-column-action {
max-width: 40px;
}
.scroll {
overflow-x: auto;
}
.text-center {
text-align: center;
}

View file

@ -0,0 +1,111 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { Config, Group, RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-import-csv',
styleUrls: ['./import-csv.component.scss'],
templateUrl: './import-csv.component.html',
})
export class RdioScannerAdminImportCsvComponent {
@Output() config = new EventEmitter<Config>();
csv: string[][] = [];
fields = [
// [id, label, description, tag, group]
[0, 3, 4, 5, 6], // trunk-recorder
[0, 2, 4, 5, 6], // radioreference.com
];
mode = 0;
tableColumns = ['id', 'label', 'description', 'tag', 'group', 'action'];
constructor(private adminService: RdioScannerAdminService) { }
async import(): Promise<void> {
const config = await this.adminService.getConfig();
this.csv.forEach((tg) => {
const group = tg[this.fields[this.mode][4]];
if (!config.groups?.find((g) => g.label === group)) {
const id = config.groups?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 0);
config.groups?.push({ _id: id, label: group });
}
const tag = tg[this.fields[this.mode][3]];
if (!config.tags?.find((t) => t.label === tag)) {
const id = config.tags?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 0);
config.tags?.push({ _id: id, label: tag });
}
});
const talkgroups = this.csv.map((csv) => {
const groupId = config.groups?.find((g) => g.label === csv[this.fields[this.mode][4]])?._id;
const tagId = config.tags?.find((t) => t.label === csv[this.fields[this.mode][3]])?._id;
return {
id: +csv[this.fields[this.mode][0]],
label: csv[this.fields[this.mode][1]],
name: csv[this.fields[this.mode][2]],
tagId,
groupId,
};
});
config.systems?.unshift({ talkgroups });
this.csv = [];
this.config.emit(config);
}
async read(event: Event): Promise<void> {
const target = (event.target as HTMLInputElement & EventTarget);
const file = target.files?.item(0);
if (!(file instanceof File)) return;
const reader = new FileReader();
reader.onloadend = () => {
target.value = '';
if (typeof reader.result !== 'string') {
return;
}
this.csv = reader.result
.split(/\n|\r\n/)
.map((tg) => tg.replace(/^"|"$/g, '').split(/"*,"*/))
.filter((tg) => tg && /^[0-9]+$/.test(tg[0]))
.filter((tg, idx, arr) => arr.findIndex((a) => a[0] === tg[0]) === idx);
};
reader.readAsBinaryString(file);
}
}

View file

@ -0,0 +1,15 @@
<div>
<p>
<span class="mat-body">Import</span><br>
<span class="mat-caption">Import a JSON file into the configuration panel where you can then review it before submitting it.</span>
</p>
<button mat-raised-button (click)="input.click()">Import</button>
<input #input type="file" style="display: none" (change)="import($event)">
</div>
<div>
<p>
<span class="mat-body">Export</span><br>
<span class="mat-caption">Export the configuration to a JSON file.</span>
</p>
<button mat-raised-button (click)="export()">Export</button>
</div>

View file

@ -0,0 +1,8 @@
:host {
> div {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
}
}

View file

@ -0,0 +1,89 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { DOCUMENT } from '@angular/common';
import { Component, EventEmitter, Inject, Output } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Config, RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-import-export-config',
styleUrls: ['./import-export-config.component.scss'],
templateUrl: './import-export-config.component.html',
})
export class RdioScannerAdminImportExportConfigComponent {
@Output() config = new EventEmitter<Config>();
constructor(
private adminService: RdioScannerAdminService,
@Inject(DOCUMENT) private document: Document,
private matSnackBar: MatSnackBar,
) { }
async export(): Promise<void> {
const config = await this.adminService.getConfig();
if ('docker' in config) {
delete config.docker;
}
const file = JSON.stringify(config);
const fileName = 'rdio-scanner.json';
const fileType = 'application/json';
const fileUri = `data:${fileType};base64,${btoa(file)}`;
const el = this.document.createElement('a');
el.style.display = 'none';
el.setAttribute('href', fileUri);
el.setAttribute('download', fileName);
this.document.body.appendChild(el);
el.click();
this.document.body.removeChild(el);
}
async import(event: Event): Promise<void> {
const target = (event.target as HTMLInputElement & EventTarget);
const file = target.files?.item(0);
if (!(file instanceof File)) return;
const reader = new FileReader();
reader.onloadend = () => {
target.value = '';
try {
const config = JSON.parse(reader.result as string);
this.config.emit(config);
} catch (error) {
this.matSnackBar.open(error.message, '', { duration: 5000 });
}
};
reader.readAsBinaryString(file);
}
}

View file

@ -0,0 +1,38 @@
<p class="mat-body">
Change the administrator password which gives access to the server administration dashboard.
</p>
<form [formGroup]="form">
<mat-form-field>
<mat-label>Current password</mat-label>
<input type="password" matInput formControlName="currentPassword" required>
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
Current password is required
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>New password</mat-label>
<input type="password" matInput formControlName="newPassword" required>
<mat-error *ngIf="form.get('newPassword')?.hasError('required')">
New password is required
</mat-error>
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
Password is too short
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Verify new password</mat-label>
<input type="password" matInput formControlName="verifyNewPassword" required>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
New password verification is required
</mat-error>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('invalid')">
Password don't match
</mat-error>
</mat-form-field>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched"
(click)="reset();">Reset</button>
<button type="button" mat-raised-button color="primary"
[disabled]="form.disabled || form.pristine || !form.valid" (click)="save()">Save</button>
</div>
</form>

View file

@ -0,0 +1,14 @@
:host > form {
display: flex;
flex-direction: column;
> div {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.mat-form-field {
margin-bottom: 1rem;
}
}

View file

@ -0,0 +1,72 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, ValidatorFn, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-password',
styleUrls: ['./password.component.scss'],
templateUrl: './password.component.html',
})
export class RdioScannerAdminPasswordComponent {
form = this.ngFormBuilder.group({
currentPassword: [null, Validators.required],
newPassword: [null, [Validators.required, Validators.minLength(8)]],
verifyNewPassword: [null, [Validators.required, this.validatePassword()]],
});
constructor(
private adminService: RdioScannerAdminService,
private matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder,
) { }
private validatePassword(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length
? control.value === control.parent?.value.newPassword
? null : { invalid: true } : null;
}
}
reset(): void {
this.form.reset();
}
async save(): Promise<void> {
const config: MatSnackBarConfig = { duration: 5000 };
this.form.disable();
try {
await this.adminService.changePassword(this.form.value.currentPassword, this.form.value.newPassword);
this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset();
} catch (_) {
this.matSnackBar.open('Unable to change password', '', config);
}
this.form.enable();
}
}

View file

@ -0,0 +1,29 @@
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>description</mat-icon>
Talkgroups CSV
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-import-csv (config)="config.emit($event)"></rdio-scanner-admin-import-csv>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>password</mat-icon>
Admin password
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-password></rdio-scanner-admin-password>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>sync_alt</mat-icon>
Import/export config
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-import-export-config (config)="config.emit($event)"></rdio-scanner-admin-import-export-config>
</mat-expansion-panel>
</mat-accordion>

View file

@ -0,0 +1,36 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Output, QueryList, ViewChildren } from '@angular/core';
import { MatExpansionPanel } from '@angular/material/expansion';
import { Config } from '../admin.service';
@Component({
selector: 'rdio-scanner-admin-tools',
templateUrl: './tools.component.html',
})
export class RdioScannerAdminToolsComponent {
@Output() config = new EventEmitter<Config>();
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
}

View file

@ -1,28 +1,25 @@
.rdio-button,
.rdio-button-mini,
.rdio-button-none {
margin: 4px;
}
.rdio-button,
.rdio-button-mini {
background: rgb(45, 45, 45);
border: none;
border-radius: 4px;
box-shadow: 1px 1px 2px rgb(255, 255, 255) inset,
-1px -1px 1px rgb(0, 0, 0) inset, 0 0 2px 2px rgb(0, 0, 0);
border-style: solid;
border-width: 1px;
border-bottom-color: rgba(0, 0, 0, 0.87);
border-left-color: rgba(255, 255, 255, 0.7);
border-right-color: rgba(0, 0, 0, 0.87);
border-top-color: rgba(255, 255, 255, 0.7);
box-sizing: border-box;
color: rgb(255, 255, 255);
font-family: inherit;
font-weight: 500;
height: 40px;
line-height: 40px;
line-height: 18px;
margin: 2px;
overflow: hidden;
padding: 2px 8px;
position: relative;
text-overflow: ellipsis;
text-overflow: clip;
text-shadow: 0 0 4px rgb(0, 0, 0);
white-space: nowrap;
width: auto;
white-space: normal;
&:active,
&:focus {
@ -30,7 +27,7 @@
}
&:active {
top: 4px;
top: 2px;
transform: scale(0.98);
transform-origin: bottom center;
}
@ -39,45 +36,36 @@
&.on::after,
&.partial::after {
content: "";
border-radius: 4px;
display: block;
height: 6px;
position: absolute;
right: 6px;
top: 6px;
right: 4px;
top: 4px;
width: 6px;
}
&.off::after {
background: rgb(230, 50, 50);
box-shadow: 1px 1px 1px rgb(255, 255, 255) inset,
0 0 3px 2px rgb(230, 50, 50);
background: #e63232;
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(230, 50, 50);
}
&.on::after {
background: rgb(100, 230, 50);
box-shadow: 1px 1px 1px rgb(255, 255, 255) inset,
0 0 3px 2px rgb(100, 230, 50);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(100, 230, 50);
}
&.partial::after {
background: rgb(230, 165, 50);
box-shadow: 1px 1px 1px rgb(255, 255, 255) inset,
0 0 3px 2px rgb(230, 165, 50);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(230, 165, 50);
}
}
.rdio-button {
color: rgb(250, 250, 250);
font-size: 12px;
min-width: 64px;
min-width: 80px;
text-transform: uppercase;
width: calc(25% - 8px);
}
.rdio-button-mini {
font-size: 10px;
min-width: 30px;
text-transform: uppercase;
width: calc(12.5% - 8px);
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -61,6 +61,12 @@
<mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()">
<mat-error *ngIf="authForm.get('password')?.hasError('expired')">
This unlock code has expired
</mat-error>
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')">
Too many connections
</mat-error>
</mat-form-field>
</form>
</div>
@ -106,9 +112,11 @@
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
LIVE<br>FEED
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
HOLD<br>SYS
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
HOLD<br>TG
</button>
@ -117,9 +125,11 @@
<button class="rdio-button" (click)="replay()">
REPLAY<br>LAST
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="skip()">
SKIP<br>NEXT
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="avoid()">
AVOID
</button>
@ -128,9 +138,11 @@
<button class="rdio-button" (click)="showSearchPanel()">
SEARCH<br>CALL
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
PAUSE
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="showSelectPanel()">
SELECT<br>TG
</button>

View file

@ -9,21 +9,26 @@
}
.rdio-control {
.rdio-button {
line-height: normal;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 12px;
.rdio-button {
flex: 1;
line-height: normal;
}
.spacer {
display: block;
width: 24px;
}
}
}
.rdio-display {
background: rgb(209, 238, 238);
border-radius: 4px;
box-shadow: 2px 2px 4px rgb(0, 0, 0) inset, 1px 1px 2px 1px rgb(255, 255, 255);
color: rgba(0, 0, 0, 0.8);
cursor: default;
@ -55,7 +60,7 @@
* {
overflow: hidden;
text-overflow: ellipsis;
text-overflow: clip;
white-space: nowrap;
}
@ -148,7 +153,6 @@
.rdio-status {
.led {
background: rgb(80, 80, 80);
border-radius: 2px;
display: block;
height: 12px;
margin-left: auto;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,7 +17,7 @@
* ****************************************************************************
*/
import { ChangeDetectorRef, Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { MatInput } from '@angular/material/input';
import { Subscription, timer } from 'rxjs';
@ -31,19 +31,19 @@ import {
RdioScannerLivefeedMap,
RdioScannerLivefeedMode,
} from '../rdio-scanner';
import { AppRdioScannerService } from '../rdio-scanner.service';
import { RdioScannerService } from '../rdio-scanner.service';
const LOCAL_STORAGE_KEY = AppRdioScannerService.LOCAL_STORAGE_KEY + '-pin';
const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
@Component({
selector: 'app-rdio-scanner-main',
selector: 'rdio-scanner-main',
styleUrls: [
'../common.scss',
'./main.component.scss',
],
templateUrl: './main.component.html',
})
export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
export class RdioScannerMainComponent implements OnDestroy, OnInit {
auth = false;
authForm = this.ngFormBuilder.group({ password: [] });
@ -98,10 +98,10 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
private dimmerTimer: Subscription | undefined;
private eventSubscription = this.appRdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
constructor(
private appRdioScannerService: AppRdioScannerService,
private rdioScannerService: RdioScannerService,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) { }
@ -109,7 +109,7 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
authenticate(password = this.authForm.value.password): void {
this.authForm.disable();
this.appRdioScannerService.authenticate(password);
this.rdioScannerService.authenticate(password);
}
authFocus(): void {
@ -118,12 +118,7 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
}
}
@HostListener('document:keydown.a', ['$event'])
avoid(options?: RdioScannerAvoidOptions, event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
avoid(options?: RdioScannerAvoidOptions): void {
if (this.auth) {
this.authFocus();
@ -131,82 +126,67 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
const call = this.call || this.callPrevious;
if (options || call) {
this.appRdioScannerService.avoid(options);
this.rdioScannerService.avoid(options);
if (call && !this.map[call.system][call.talkgroup]) {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
}
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Denied);
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
}
this.updateDimmer();
}
}
@HostListener('document:keydown.s', ['$event'])
holdSystem(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
holdSystem(): void {
if (this.auth) {
this.authFocus();
} else {
if (this.call || this.callPrevious) {
this.appRdioScannerService.beep(this.holdSys ? RdioScannerBeepStyle.Deactivate : RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(this.holdSys ? RdioScannerBeepStyle.Deactivate : RdioScannerBeepStyle.Activate);
this.appRdioScannerService.holdSystem();
this.rdioScannerService.holdSystem();
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Denied);
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
}
this.updateDimmer();
}
}
@HostListener('document:keydown.t', ['$event'])
holdTalkgroup(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
holdTalkgroup(): void {
if (this.auth) {
this.authFocus();
} else {
if (this.call || this.callPrevious) {
this.appRdioScannerService.beep(this.holdTg ? RdioScannerBeepStyle.Deactivate : RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(this.holdTg ? RdioScannerBeepStyle.Deactivate : RdioScannerBeepStyle.Activate);
this.appRdioScannerService.holdTalkgroup();
this.rdioScannerService.holdTalkgroup();
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Denied);
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
}
this.updateDimmer();
}
}
@HostListener('document:keydown.l', ['$event'])
livefeed(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
livefeed(): void {
if (this.auth) {
this.authFocus();
} else {
this.appRdioScannerService.beep(this.livefeedOffline ? RdioScannerBeepStyle.Activate : RdioScannerBeepStyle.Deactivate);
this.rdioScannerService.beep(this.livefeedOffline ? RdioScannerBeepStyle.Activate : RdioScannerBeepStyle.Deactivate);
this.appRdioScannerService.livefeed();
this.rdioScannerService.livefeed();
this.updateDimmer();
}
@ -222,49 +202,36 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
this.syncClock();
}
@HostListener('document:keydown.space', ['$event'])
@HostListener('document:keydown.p', ['$event'])
pause(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
event?.preventDefault();
pause(): void {
if (this.auth) {
this.authFocus();
} else {
if (this.livefeedPaused) {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Deactivate).then(() => this.appRdioScannerService.pause());
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate).then(() => this.rdioScannerService.pause());
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.appRdioScannerService.pause();
this.rdioScannerService.pause();
}
this.updateDimmer();
}
}
@HostListener('document:keydown.r', ['$event'])
replay(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
replay(): void {
if (this.auth) {
this.authFocus();
} else {
if (!this.livefeedPaused && (this.call || this.callPrevious)) {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.appRdioScannerService.replay();
this.rdioScannerService.replay();
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Denied);
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
}
this.updateDimmer();
@ -280,7 +247,7 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
this.authFocus();
} else {
this.appRdioScannerService.beep();
this.rdioScannerService.beep();
this.openSearchPanel.emit();
}
@ -295,32 +262,27 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
this.authFocus();
} else {
this.appRdioScannerService.beep();
this.rdioScannerService.beep();
this.openSelectPanel.emit();
}
}
@HostListener('document:keydown.n', ['$event'])
skip(options?: { delay?: boolean }, event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
skip(options?: { delay?: boolean }): void {
if (this.auth) {
this.authFocus();
} else {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.appRdioScannerService.skip(options);
this.rdioScannerService.skip(options);
this.updateDimmer();
}
}
stop(): void {
this.appRdioScannerService.stop();
this.rdioScannerService.stop();
}
private eventHandler(event: RdioScannerEvent): void {
@ -338,7 +300,7 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
if (password) {
this.authForm.get('password')?.setValue(password);
this.appRdioScannerService.authenticate(password);
this.rdioScannerService.authenticate(password);
} else {
this.auth = event.auth;
@ -385,6 +347,10 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
}
}
if ('expired' in event && event.expired === true) {
this.authForm.get('password')?.setErrors({ expired: true });
}
if ('holdSys' in event) {
this.holdSys = event.holdSys || false;
}
@ -423,6 +389,10 @@ export class AppRdioScannerMainComponent implements OnDestroy, OnInit {
this.updateDimmer();
}
if ('tooMany' in event && event.tooMany === true) {
this.authForm.get('password')?.setErrors({ tooMany: true });
}
this.updateDisplay();
}

View file

@ -10,7 +10,7 @@
</mat-toolbar>
<div #scrollableSearch>
<div>
<app-rdio-scanner-search #searchComponent></app-rdio-scanner-search>
<rdio-scanner-search #searchComponent></rdio-scanner-search>
</div>
</div>
</div>
@ -26,16 +26,16 @@
</mat-toolbar>
<div #scrollableSelect>
<div>
<app-rdio-scanner-select></app-rdio-scanner-select>
<rdio-scanner-select></rdio-scanner-select>
</div>
</div>
</div>
</mat-sidenav>
<mat-sidenav-content>
<app-rdio-scanner-main
<rdio-scanner-main
(openSearchPanel)="scrollableSearch.scrollTo(0, 0); searchPanel.open(); searchComponent.searchCalls()"
(openSelectPanel)="scrollableSelect.scrollTo(0, 0); selectPanel.open();"
(toggleFullscreen)="toggleFullscreen()">
</app-rdio-scanner-main>
</rdio-scanner-main>
</mat-sidenav-content>
</mat-sidenav-container>

View file

@ -45,7 +45,7 @@ $background-color: rgb(30, 30, 30);
.mat-toolbar {
background-color: inherit;
box-shadow: 0 8px 20px rgba($background-color, 0.9);
box-shadow: 0 4px 8px rgba($background-color, 0.8);
color: currentColor;
z-index: 2;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,17 +20,17 @@
import { Component, ElementRef, HostListener, OnDestroy, ViewChild } from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
import { RdioScannerConfig, RdioScannerEvent, RdioScannerLivefeedMode } from './rdio-scanner';
import { AppRdioScannerService } from './rdio-scanner.service';
import { RdioScannerService } from './rdio-scanner.service';
@Component({
selector: 'app-rdio-scanner',
selector: 'rdio-scanner',
styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html',
})
export class AppRdioScannerComponent implements OnDestroy {
export class RdioScannerComponent implements OnDestroy {
private config: RdioScannerConfig | undefined;
private eventSubscription = this.appRdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private livefeedMode: RdioScannerLivefeedMode = RdioScannerLivefeedMode.Offline;
@ -39,8 +39,8 @@ export class AppRdioScannerComponent implements OnDestroy {
@ViewChild('selectPanel') private selectPanel: MatSidenav | undefined;
constructor(
private appRdioScannerService: AppRdioScannerService,
private ngElementRef: ElementRef,
private rdioScannerService: RdioScannerService,
) { }
@HostListener('window:beforeunload', ['$event'])
@ -53,11 +53,11 @@ export class AppRdioScannerComponent implements OnDestroy {
}
start(): void {
this.appRdioScannerService.startLivefeed();
this.rdioScannerService.startLivefeed();
}
stop(): void {
this.appRdioScannerService.stopLivefeed();
this.rdioScannerService.stopLivefeed();
this.searchPanel?.close();
this.selectPanel?.close();
@ -67,15 +67,7 @@ export class AppRdioScannerComponent implements OnDestroy {
this.eventSubscription.unsubscribe();
}
@HostListener('document:keydown.f', ['$event'])
@HostListener('document:keydown.tab', ['$event'])
toggleFullscreen(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
event?.preventDefault();
toggleFullscreen(): void {
if (document.fullscreenElement) {
const el: {
exitFullscreen?: () => void;
@ -124,36 +116,4 @@ export class AppRdioScannerComponent implements OnDestroy {
this.livefeedMode = event.livefeedMode;
}
}
@HostListener('document:keydown.arrowleft', ['$event'])
private keyLeftArrow(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
event?.preventDefault();
if (this.selectPanel?.opened) {
this.selectPanel?.close();
} else {
this.searchPanel?.open();
}
}
@HostListener('document:keydown.arrowright', ['$event'])
private keyRightArrow(event?: KeyboardEvent): void {
if (event && !this.config?.keyboardShortcuts) {
return;
}
event?.preventDefault();
if (this.searchPanel?.opened) {
this.searchPanel?.close();
} else {
this.selectPanel?.open();
}
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,24 +20,24 @@
import { FullscreenOverlayContainer, OverlayContainer } from '@angular/cdk/overlay';
import { NgModule } from '@angular/core';
import { AppSharedModule } from '../../shared/shared.module';
import { AppRdioScannerComponent } from './rdio-scanner.component';
import { AppRdioScannerService } from './rdio-scanner.service';
import { AppRdioScannerMainComponent } from './main/main.component';
import { AppRdioScannerSearchComponent } from './search/search.component';
import { AppRdioScannerSelectComponent } from './select/select.component';
import { RdioScannerComponent } from './rdio-scanner.component';
import { RdioScannerService } from './rdio-scanner.service';
import { RdioScannerMainComponent } from './main/main.component';
import { RdioScannerSearchComponent } from './search/search.component';
import { RdioScannerSelectComponent } from './select/select.component';
@NgModule({
declarations: [
AppRdioScannerComponent,
AppRdioScannerMainComponent,
AppRdioScannerSearchComponent,
AppRdioScannerSelectComponent,
RdioScannerComponent,
RdioScannerMainComponent,
RdioScannerSearchComponent,
RdioScannerSelectComponent,
],
exports: [AppRdioScannerComponent],
exports: [RdioScannerComponent],
imports: [AppSharedModule],
providers: [
AppRdioScannerService,
RdioScannerService,
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer },
],
})
export class AppRdioScannerModule { }
export class RdioScannerModule { }

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -49,13 +49,16 @@ enum WebsocketCallFlag {
enum WebsocketCommand {
Call = 'CAL',
Config = 'CFG',
Expired = 'XPR',
ListCall = 'LCL',
LivefeedMap = 'LFM',
Max = 'MAX',
Pin = 'PIN',
Ver = 'VER',
}
@Injectable()
export class AppRdioScannerService implements OnDestroy {
export class RdioScannerService implements OnDestroy {
static LOCAL_STORAGE_KEY = 'rdio-scanner';
event = new EventEmitter<RdioScannerEvent>();
@ -74,7 +77,6 @@ export class AppRdioScannerService implements OnDestroy {
private config: RdioScannerConfig = {
dimmerDelay: false,
groups: {},
keyboardShortcuts: true,
keypadBeeps: false,
systems: [],
tags: {},
@ -331,10 +333,18 @@ export class AppRdioScannerService implements OnDestroy {
}
loadAndDownload(id: string): void {
if (!id) {
return;
}
this.getCall(id, WebsocketCallFlag.Download);
}
loadAndPlay(id: string): void {
if (!id) {
return;
}
if (this.skipDelay) {
this.skipDelay.unsubscribe();
@ -765,16 +775,18 @@ export class AppRdioScannerService implements OnDestroy {
if (Array.isArray(message)) {
switch (message[0]) {
case WebsocketCommand.Call:
if (message[2] === WebsocketCallFlag.Download) {
this.download(message[1]);
if (message[1] !== null) {
if (message[2] === WebsocketCallFlag.Download) {
this.download(message[1]);
} else if (message[2] === WebsocketCallFlag.Play && message[1]?.id === this.playbackPending) {
this.playbackPending = undefined;
} else if (message[2] === WebsocketCallFlag.Play && message[1]?.id === this.playbackPending) {
this.playbackPending = undefined;
this.queue(this.transformCall(message[1]), { priority: true });
this.queue(this.transformCall(message[1]), { priority: true });
} else {
this.queue(this.transformCall(message[1]));
} else {
this.queue(this.transformCall(message[1]));
}
}
break;
@ -785,7 +797,6 @@ export class AppRdioScannerService implements OnDestroy {
this.config = {
dimmerDelay: typeof config.dimmerDelay === 'number' ? config.dimmerDelay : 5000,
groups: typeof config.groups !== null && typeof config.groups === 'object' ? config.groups : {},
keyboardShortcuts: config.keyboardShortcuts,
keypadBeeps: config.keypadBeeps !== null && typeof config.keypadBeeps === 'object' ? config.keypadBeeps : {},
systems: Array.isArray(config.systems) ? config.systems.slice() : [],
tags: typeof config.tags !== null && typeof config.tags === 'object' ? config.tags : {},
@ -808,6 +819,11 @@ export class AppRdioScannerService implements OnDestroy {
break;
case WebsocketCommand.Expired:
this.event.emit({ auth: true, expired: true });
break;
case WebsocketCommand.ListCall:
this.playbackList = message[1];
@ -823,6 +839,11 @@ export class AppRdioScannerService implements OnDestroy {
break;
case WebsocketCommand.Max:
this.event.emit({ auth: true, tooMany: true });
break;
case WebsocketCommand.Pin:
this.event.emit({ auth: true });
@ -893,7 +914,7 @@ export class AppRdioScannerService implements OnDestroy {
}
private rebuildLivefeedMap(): void {
this.livefeedMap = this.config.systems.reduce((sysMap, sys) => {
const livefeedMap = this.config.systems.reduce((sysMap, sys) => {
const tgs = sys.talkgroups.map((tg) => tg.id.toString());
sysMap[sys.id] = sys.talkgroups.reduce((tgMap, tg) => {
@ -907,6 +928,14 @@ export class AppRdioScannerService implements OnDestroy {
return sysMap;
}, {} as RdioScannerLivefeedMap);
if (this.livefeedMapPriorToHoldSystem != null) {
this.livefeedMapPriorToHoldSystem = livefeedMap;
} else if (this.livefeedMapPriorToHoldTalkgroup != null) {
this.livefeedMapPriorToHoldTalkgroup = livefeedMap;
} else {
this.livefeedMap = livefeedMap;
}
this.storeLivefeedMap();
this.rebuildGroups();
@ -919,7 +948,7 @@ export class AppRdioScannerService implements OnDestroy {
}
private restoreLivefeed(): void {
const map = window?.localStorage?.getItem(AppRdioScannerService.LOCAL_STORAGE_KEY);
const map = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY);
if (map) {
try {
@ -948,11 +977,11 @@ export class AppRdioScannerService implements OnDestroy {
}
private storeLivefeedMap(): void {
window?.localStorage?.setItem(AppRdioScannerService.LOCAL_STORAGE_KEY, JSON.stringify(this.livefeedMap));
window?.localStorage?.setItem(RdioScannerService.LOCAL_STORAGE_KEY, JSON.stringify(this.livefeedMap));
}
private transformCall(call: RdioScannerCall): RdioScannerCall {
if (Array.isArray(this.config?.systems)) {
if (call && Array.isArray(this.config?.systems)) {
call.systemData = this.config.systems.find((system) => system.id === call.system);
if (Array.isArray(call.systemData?.talkgroups)) {

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -73,7 +73,6 @@ export interface RdioScannerCallSource {
export interface RdioScannerConfig {
dimmerDelay: number | false;
groups: { [key: string]: { [key: number]: number[] } };
keyboardShortcuts: boolean;
keypadBeeps: RdioScannerKeypadBeeps | false;
systems: RdioScannerSystem[];
tags: { [key: string]: { [key: number]: number[] } };
@ -83,6 +82,7 @@ export interface RdioScannerEvent {
auth?: boolean;
config?: RdioScannerConfig;
call?: RdioScannerCall;
expired?: boolean;
groups?: RdioScannerGroup[];
holdSys?: boolean;
holdTg?: boolean;
@ -94,6 +94,7 @@ export interface RdioScannerEvent {
playbackPending?: string;
queue?: number;
time?: number;
tooMany?: boolean;
}
export enum RdioScannerGroupStatus {
@ -150,7 +151,7 @@ export interface RdioScannerSystem {
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'red' | 'white' | 'yellow';
order?: number;
talkgroups: RdioScannerTalkgroup[];
units?: RdioScannerUnit[];
units: RdioScannerUnit[];
}
export interface RdioScannerTalkgroup {

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -31,14 +31,14 @@ import {
RdioScannerSystem,
RdioScannerTalkgroup,
} from '../rdio-scanner';
import { AppRdioScannerService } from '../rdio-scanner.service';
import { RdioScannerService } from '../rdio-scanner.service';
@Component({
selector: 'app-rdio-scanner-search',
selector: 'rdio-scanner-search',
styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html',
})
export class AppRdioScannerSearchComponent implements OnDestroy {
export class RdioScannerSearchComponent implements OnDestroy {
call: RdioScannerCall | undefined;
callPending: string | undefined;
@ -68,7 +68,7 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
private config: RdioScannerConfig | undefined;
private eventSubscription = this.appRdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private limit = 200;
@ -77,18 +77,18 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
@ViewChild(MatPaginator, { read: MatPaginator }) private paginator: MatPaginator | undefined;
constructor(
private appRdioScannerService: AppRdioScannerService,
private rdioScannerService: RdioScannerService,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) { }
download(id: string): void {
this.appRdioScannerService.loadAndDownload(id);
this.rdioScannerService.loadAndDownload(id);
}
formChangeHandler(): void {
if (this.livefeedPlayback) {
this.appRdioScannerService.stopPlaybackMode();
this.rdioScannerService.stopPlaybackMode();
}
this.paginator?.firstPage();
@ -127,16 +127,14 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
.filter((tag) => this.config?.systems
.some((system) => system.talkgroups
.some((talkgroup) => talkgroup.group === selectedGroup && talkgroup.tag === tag)))
.sort()
: selectedTalkgroup
? [selectedTalkgroup.tag]
: selectedSystem
? Object.keys(this.config.tags)
.filter((tag) => selectedSystem.talkgroups
.some((talkgroup) => talkgroup.tag === tag))
.sort()
: Object.keys(this.config.tags)
.sort();
this.optionsTag.sort((a, b) => a.localeCompare(b));
this.optionsTalkgroup = selectedGroup
? selectedSystem
@ -176,18 +174,16 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
.filter((group) => Object.keys(this.config?.groups[group] || {})
.map((system) => +system)
.includes(selectedSystem.id))
.sort()
: Object.keys(this.config.groups)
.sort();
this.optionsGroup.sort((a, b) => a.localeCompare(b));
this.optionsTag = selectedSystem
? Object.keys(this.config.tags)
.filter((tag) => Object.keys(this.config?.tags[tag] || {})
.map((system) => +system)
.includes(selectedSystem.id))
.sort()
: Object.keys(this.config.tags)
.sort();
this.optionsTag.sort((a, b) => a.localeCompare(b));
this.optionsTalkgroup = selectedSystem
? selectedSystem.talkgroups
@ -223,16 +219,14 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
.filter((group) => this.config?.systems
.some((system) => system.talkgroups
.some((talkgroup) => talkgroup.group === group && talkgroup.tag === selectedTag)))
.sort()
: selectedTalkgroup
? [selectedTalkgroup.group]
: selectedSystem
? Object.keys(this.config.groups)
.filter((group) => selectedSystem.talkgroups
.some((talkgroup) => talkgroup.group === group))
.sort()
: Object.keys(this.config.groups)
.sort();
this.optionsGroup.sort((a, b) => a.localeCompare(b));
this.optionsSystem = selectedTag
? this.config.systems
@ -283,26 +277,22 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
this.optionsGroup = selectedTalkgroup
? Object.keys(this.config.groups)
.filter((group) => group === selectedTalkgroup.group)
.sort()
: selectedSystem
? Object.keys(this.config.groups)
.filter((group) => selectedSystem.talkgroups
.some((talkgroup) => talkgroup.group === group))
.sort()
: Object.keys(this.config.groups)
.sort();
this.optionsGroup.sort((a, b) => a.localeCompare(b));
this.optionsTag = selectedTalkgroup
? Object.keys(this.config.tags)
.filter((tag) => tag === selectedTalkgroup.tag)
.sort()
: selectedSystem
? Object.keys(this.config.tags)
.filter((tag) => selectedSystem.talkgroups
.some((talkgroup) => talkgroup.tag === tag))
.sort()
: Object.keys(this.config.tags)
.sort();
this.optionsTag.sort((a, b) => a.localeCompare(b));
this.form.patchValue({
group: selectedGroup ? this.optionsGroup.findIndex((group) => group === selectedGroup) : -1,
@ -317,7 +307,7 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
}
play(id: string): void {
this.appRdioScannerService.loadAndPlay(id);
this.rdioScannerService.loadAndPlay(id);
}
refreshResults(): void {
@ -353,11 +343,6 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
talkgroup: -1,
});
this.optionsGroup = Object.keys(this.config?.groups || []);
this.optionsSystem = (this.config?.systems || []).map((system) => system.label);
this.optionsTag = Object.keys(this.config?.tags || []);
this.optionsTalkgroup = [];
this.paginator?.firstPage();
this.formChangeHandler();
@ -420,15 +405,15 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
this.form.disable();
this.appRdioScannerService.searchCalls(options);
this.rdioScannerService.searchCalls(options);
}
stop(): void {
if (this.livefeedPlayback) {
this.appRdioScannerService.stopPlaybackMode();
this.rdioScannerService.stopPlaybackMode();
} else {
this.appRdioScannerService.stop();
this.rdioScannerService.stop();
}
}
@ -455,7 +440,12 @@ export class AppRdioScannerSearchComponent implements OnDestroy {
if ('config' in event) {
this.config = event.config;
this.resetForm();
this.callPending = undefined;
this.optionsGroup = Object.keys(this.config?.groups || []).sort((a, b) => a.localeCompare(b));
this.optionsSystem = (this.config?.systems || []).map((system) => system.label);
this.optionsTag = Object.keys(this.config?.tags || []).sort((a, b) => a.localeCompare(b));
this.optionsTalkgroup = [];
}
if ('livefeedMode' in event) {

View file

@ -12,23 +12,28 @@
</button>
</div>
</fieldset>
<fieldset *ngFor="let system of systems" class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-button"
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]), on: map[system.id] && map[system.id][talkgroup.id] }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
<ng-container *ngIf="system.talkgroups.length > 1">
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
<ng-container *ngFor="let system of systems">
<fieldset *ngIf="system.talkgroups.length" class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-button"
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]), on: map[system.id] && map[system.id][talkgroup.id] }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
</ng-container>
</div>
</fieldset>
<ng-container *ngIf="system.talkgroups.length > 1">
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
</ng-container>
</div>
</fieldset>
</ng-container>
<ng-container *ngIf="systems?.length === 0">
No talkgroup configured!
</ng-container>

View file

@ -39,25 +39,34 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
}
}
.rdio-button {
width: calc(25% - 4px);
}
.rdio-button-mini {
width: calc(12.5% - 4px);
}
@media (min-width: 720px) {
.rdio-button {
width: calc(20% - 8px);
width: calc(20% - 4px);
}
.rdio-button-mini {
width: calc(10% - 8px);
width: calc(10% - 4px);
}
}
@media (min-width: 1200px) {
.rdio-button {
width: calc(10% - 8px);
width: calc(10% - 4px);
}
.rdio-button-mini {
width: calc(5% - 8px);
width: calc(5% - 4px);
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -19,31 +19,31 @@
import { Component, OnDestroy } from '@angular/core';
import { RdioScannerAvoidOptions, RdioScannerBeepStyle, RdioScannerEvent, RdioScannerGroup, RdioScannerLivefeedMap, RdioScannerSystem } from '../rdio-scanner';
import { AppRdioScannerService } from '../rdio-scanner.service';
import { RdioScannerService } from '../rdio-scanner.service';
@Component({
selector: 'app-rdio-scanner-select',
selector: 'rdio-scanner-select',
styleUrls: [
'../common.scss',
'./select.component.scss',
],
templateUrl: './select.component.html',
})
export class AppRdioScannerSelectComponent implements OnDestroy {
export class RdioScannerSelectComponent implements OnDestroy {
groups: RdioScannerGroup[] | undefined;
map: RdioScannerLivefeedMap = {};
systems: RdioScannerSystem[] | undefined;
private eventSubscription = this.appRdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
constructor(private appRdioScannerService: AppRdioScannerService) { }
constructor(private rdioScannerService: RdioScannerService) { }
avoid(options?: RdioScannerAvoidOptions): void {
this.appRdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
this.appRdioScannerService.avoid(options);
this.rdioScannerService.avoid(options);
}
ngOnDestroy(): void {
@ -51,14 +51,14 @@ export class AppRdioScannerSelectComponent implements OnDestroy {
}
toggleGroup(label: string): void {
this.appRdioScannerService.beep();
this.rdioScannerService.beep();
this.appRdioScannerService.toggleGroup(label);
this.rdioScannerService.toggleGroup(label);
}
private eventHandler(event: RdioScannerEvent): void {
if (event.config) {
this.systems = this.sortSystems(event.config.systems);
this.systems = event.config.systems;
}
if (event.groups) {
@ -69,21 +69,4 @@ export class AppRdioScannerSelectComponent implements OnDestroy {
this.map = event.map;
}
}
private sortSystems(systems: RdioScannerSystem[]): RdioScannerSystem[] {
return systems.sort((sysA, sysB) => {
if (typeof sysA.order === 'number' && typeof sysB.order !== 'number') {
return -1;
} else if (typeof sysA.order !== 'number' && typeof sysB.order === 'number') {
return 1;
} else if (typeof sysA.order === 'number' && typeof sysB.order === 'number') {
return sysA.order - sysB.order;
} else {
return sysA.id - sysB.id;
}
});
}
}

View file

@ -0,0 +1,6 @@
<h1 class="mat-display-2">Rdio Scanner server administration</h1>
<div>
<mat-card>
<rdio-scanner-admin></rdio-scanner-admin>
</mat-card>
</div>

View file

@ -0,0 +1,35 @@
:host {
background: #1e1e1e;
box-sizing: border-box;
display: block;
min-height: 100vh;
padding: 16px;
> div {
margin: auto;
max-width: 1200px;
}
> h1 {
color: #2d2d2d;
letter-spacing: 0.01rem;
margin: 0;
padding: 16px 0;
text-align: center;
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.83);
}
}
@media (max-width: 719px) {
:host > h1 {
font-size: 28px;
line-height: 32px;
}
.mat-card {
padding: 8px;
}
}
@media (min-width: 720px) {
}

View file

@ -0,0 +1,27 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component } from '@angular/core';
@Component({
selector: 'rdio-scanner-admin-page',
styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html',
})
export class RdioScannerAdminPageComponent { }

View file

@ -0,0 +1,34 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { NgModule } from '@angular/core';
import { RdioScannerAdminModule } from '../../../components/rdio-scanner/admin';
import { AppSharedModule } from '../../../shared/shared.module';
import { RdioScannerAdminPageComponent } from './admin.component';
import { routes } from './admin.routes';
@NgModule({
declarations: [RdioScannerAdminPageComponent],
exports: [RdioScannerAdminPageComponent],
imports: [
RdioScannerAdminModule,
AppSharedModule.forChild({routerRoutes: routes }),
],
})
export class RdioScannerAdminPageModule { }

View file

@ -0,0 +1,28 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Routes } from '@angular/router';
import { RdioScannerAdminPageComponent } from './admin.component';
export const routes: Routes = [
{
path: '',
component: RdioScannerAdminPageComponent,
},
];

View file

@ -0,0 +1,22 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
export * from './rdio-scanner.component';
export * from './rdio-scanner.module';
export * from './rdio-scanner.routes';

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,17 +17,10 @@
* ****************************************************************************
*/
'use strict';
import { Component } from '@angular/core';
const callFactory = require('./call');
const systemFactory = require('./system');
class Models {
constructor(ctx = {}) {
this.call = callFactory(ctx);
this.system = systemFactory(ctx);
}
}
module.exports = Models;
@Component({
selector: 'rdio-scanner-main-page',
template: '<rdio-scanner></rdio-scanner>',
})
export class RdioScannerMainPageComponent { }

View file

@ -0,0 +1,26 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component } from '@angular/core';
@Component({
selector: 'rdio-scanner-page',
template: '<router-outlet></router-outlet>',
})
export class RdioScannerPageComponent { }

View file

@ -0,0 +1,38 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { NgModule } from '@angular/core';
import { RdioScannerModule } from '../../components/rdio-scanner';
import { AppSharedModule } from '../../shared/shared.module';
import { RdioScannerPageComponent } from './rdio-scanner.component';
import { RdioScannerMainPageComponent } from './rdio-scanner-main.component';
import { routes } from './rdio-scanner.routes';
@NgModule({
declarations: [
RdioScannerPageComponent,
RdioScannerMainPageComponent,
],
exports: [RdioScannerPageComponent],
imports: [
RdioScannerModule,
AppSharedModule.forChild({routerRoutes: routes }),
],
})
export class RdioScannerPageModule { }

View file

@ -0,0 +1,39 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Routes } from '@angular/router';
import { RdioScannerMainPageComponent } from './rdio-scanner-main.component';
import { RdioScannerPageComponent } from './rdio-scanner.component';
export const routes: Routes = [
{
path: '',
component: RdioScannerPageComponent,
children: [
{
component: RdioScannerMainPageComponent,
path: '',
},
{
loadChildren: () => import('./admin/admin.module').then((module) => module.RdioScannerAdminPageModule),
path: 'admin',
},
],
},
];

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,36 +17,50 @@
* ****************************************************************************
*/
import { DragDropModule } from '@angular/cdk/drag-drop';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
@NgModule({
exports: [
DragDropModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatDatepickerModule,
MatDialogModule,
MatExpansionModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatListModule,
MatNativeDateModule,
MatPaginatorModule,
MatProgressBarModule,
MatRadioModule,
MatSelectModule,
MatSidenavModule,
MatSlideToggleModule,
MatSnackBarModule,
MatTableModule,
MatToolbarModule,
],

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,9 +20,15 @@
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ExtraOptions, RouterModule, Routes } from '@angular/router';
import { AppLocaleModule } from './locale/locale.module';
import { AppMaterialModule } from './material/material.module';
export interface AppSharedModuleConfig {
routerExtraOptions?: ExtraOptions;
routerRoutes?: Routes;
}
@NgModule({
exports: [
AppLocaleModule,
@ -30,14 +36,25 @@ import { AppMaterialModule } from './material/material.module';
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
],
})
export class AppSharedModule {
static forRoot(): ModuleWithProviders<AppSharedModule> {
static forChild(config: AppSharedModuleConfig = {}): ModuleWithProviders<AppSharedModule> {
return {
ngModule: AppSharedModule,
providers: [
...RouterModule.forChild(config.routerRoutes || []).providers || [],
],
};
}
static forRoot(config: AppSharedModuleConfig = {}): ModuleWithProviders<AppSharedModule> {
return {
ngModule: AppSharedModule,
providers: [
...AppLocaleModule.forRoot().providers || [],
...RouterModule.forRoot(config.routerRoutes || [], config.routerExtraOptions || {}).providers || [],
],
};
}

View file

@ -6,7 +6,8 @@
<link rel="apple-touch-icon" sizes="192x192" href="assets/icons/icon-192x192.png">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="manifest.webmanifest">
<link rel="stylesheet" href="///fonts.googleapis.com/css?family=Roboto:400,500|Material+Icons">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Icons&family=Roboto:wght@300;400;500&display=block">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Rdio Scanner</title>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2021 Chrystian Huot
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,5 +27,4 @@ if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch((error) => console.error(error));
platformBrowserDynamic().bootstrapModule(AppModule).catch((error) => console.error(error));

View file

@ -57,4 +57,4 @@
"purpose": "maskable any"
}
]
}
}

Some files were not shown because too many files have changed in this diff Show more