Add calls UI (#84)
Also make a PWA. Closes #81 Reviewed-on: #84 Co-authored-by: Daniel Ponte <amigan@gmail.com> Co-committed-by: Daniel Ponte <amigan@gmail.com>
This commit is contained in:
parent
77a08679d4
commit
dac1f5fa4e
62 changed files with 3423 additions and 2688 deletions
client/stillbox
README.mdangular.jsonngsw-config.jsonpackage-lock.jsonpackage.json
public
icons
icon-128x128.pngicon-144x144.pngicon-152x152.pngicon-192x192.pngicon-384x384.pngicon-512x512.pngicon-72x72.pngicon-96x96.png
manifest.webmanifestsrc
app
alerts
app.component.htmlapp.component.tsapp.config.tsapp.routes.tsauth.guard.tscalls.tscalls
home
incidents
login
navigation
navigation.component.htmlnavigation.component.tstoolbar-context.service.spec.tstoolbar-context.service.ts
prefs
talkgroups
export
import
talkgroup-record
talkgroup-table
talkgroups.component.scsstalkgroups.component.tstalkgroups.service.tsversion
assets
index.htmlstyles.scsspkg
sql/postgres/queries
|
@ -1,6 +1,4 @@
|
|||
# Admin
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.10.
|
||||
# Stillbox
|
||||
|
||||
## Development server
|
||||
|
||||
|
@ -23,5 +21,3 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
|
|||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
|
|
@ -54,7 +54,8 @@
|
|||
"maximumError": "4kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"serviceWorker": "ngsw-config.json"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
|
30
client/stillbox/ngsw-config.json
Normal file
30
client/stillbox/ngsw-config.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.csr.html",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
4321
client/stillbox/package-lock.json
generated
4321
client/stillbox/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -10,25 +10,26 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.2.0",
|
||||
"@angular/cdk": "^17.0.0",
|
||||
"@angular/common": "^18.2.0",
|
||||
"@angular/compiler": "^18.2.0",
|
||||
"@angular/core": "^18.2.0",
|
||||
"@angular/forms": "^18.2.0",
|
||||
"@angular/material": "^17.0.0",
|
||||
"@angular/platform-browser": "^18.2.0",
|
||||
"@angular/platform-browser-dynamic": "^18.2.0",
|
||||
"@angular/router": "^18.2.0",
|
||||
"@angular/animations": "^19.0.5",
|
||||
"@angular/cdk": "^19.0.4",
|
||||
"@angular/common": "^19.0.5",
|
||||
"@angular/compiler": "^19.0.5",
|
||||
"@angular/core": "^19.0.5",
|
||||
"@angular/forms": "^19.0.5",
|
||||
"@angular/material": "^19.0.4",
|
||||
"@angular/platform-browser": "^19.0.5",
|
||||
"@angular/platform-browser-dynamic": "^19.0.5",
|
||||
"@angular/router": "^19.0.5",
|
||||
"@angular/service-worker": "^19.0.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"sass": "^1.82.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.10"
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.2.10",
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.0",
|
||||
"@angular-devkit/build-angular": "^19.0.6",
|
||||
"@angular/cli": "^19.0.6",
|
||||
"@angular/compiler-cli": "^19.0.5",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.2.0",
|
||||
"karma": "~6.4.0",
|
||||
|
|
BIN
client/stillbox/public/icons/icon-128x128.png
Normal file
BIN
client/stillbox/public/icons/icon-128x128.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 7.1 KiB |
BIN
client/stillbox/public/icons/icon-144x144.png
Normal file
BIN
client/stillbox/public/icons/icon-144x144.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 8.1 KiB |
BIN
client/stillbox/public/icons/icon-152x152.png
Normal file
BIN
client/stillbox/public/icons/icon-152x152.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 8.7 KiB |
BIN
client/stillbox/public/icons/icon-192x192.png
Normal file
BIN
client/stillbox/public/icons/icon-192x192.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 12 KiB |
BIN
client/stillbox/public/icons/icon-384x384.png
Normal file
BIN
client/stillbox/public/icons/icon-384x384.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 29 KiB |
BIN
client/stillbox/public/icons/icon-512x512.png
Normal file
BIN
client/stillbox/public/icons/icon-512x512.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 48 KiB |
BIN
client/stillbox/public/icons/icon-72x72.png
Normal file
BIN
client/stillbox/public/icons/icon-72x72.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 3.6 KiB |
BIN
client/stillbox/public/icons/icon-96x96.png
Normal file
BIN
client/stillbox/public/icons/icon-96x96.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 5.1 KiB |
59
client/stillbox/public/manifest.webmanifest
Normal file
59
client/stillbox/public/manifest.webmanifest
Normal file
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "Stillbox",
|
||||
"short_name": "Stillbox",
|
||||
"theme_color": "#1976d2",
|
||||
"background_color": "#fafafa",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
|||
|
||||
@Component({
|
||||
selector: 'app-alerts',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './alerts.component.html',
|
||||
styleUrl: './alerts.component.scss',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<!-- ========== HEADER ========== -->
|
||||
<app-update-nag></app-update-nag>
|
||||
<header class="topHeader">
|
||||
<mat-toolbar>
|
||||
<div class="navbar">
|
||||
|
|
|
@ -1,23 +1,30 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatSidenavModule, MatSidenav } from '@angular/material/sidenav';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService } from './login/auth.service';
|
||||
import { NavigationComponent } from './navigation/navigation.component';
|
||||
import { Subject } from 'rxjs';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { UpdateNagComponent } from './version/update-nag/update-nag.component';
|
||||
import {
|
||||
Router,
|
||||
RouterModule,
|
||||
NavigationEnd,
|
||||
ActivatedRoute,
|
||||
} from '@angular/router';
|
||||
import { filter, map } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
FormsModule,
|
||||
NavigationComponent,
|
||||
UpdateNagComponent,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
|
@ -27,12 +34,42 @@ export class AppComponent {
|
|||
title = 'stillbox';
|
||||
opened!: boolean;
|
||||
toggleNavSubject: Subject<void> = new Subject<void>();
|
||||
titleSub!: Subscription;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private titleService: Title,
|
||||
) {}
|
||||
|
||||
toggleNav() {
|
||||
this.toggleNavSubject.next();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.titleSub = this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map(() => {
|
||||
let route: ActivatedRoute = this.router.routerState.root;
|
||||
let routeTitle = '';
|
||||
while (route!.firstChild) {
|
||||
route = route.firstChild;
|
||||
}
|
||||
if (route.snapshot.data['title']) {
|
||||
routeTitle = route!.snapshot.data['title'];
|
||||
}
|
||||
return routeTitle;
|
||||
}),
|
||||
)
|
||||
.subscribe((title: string) => {
|
||||
if (title) {
|
||||
this.titleService.setTitle(`${title} | Stillbox`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.titleSub.unsubscribe();
|
||||
this.auth.logout();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { AuthService } from './login/auth.service';
|
|||
import { routes } from './app.routes';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
export function apiBaseInterceptor(
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn,
|
||||
|
@ -45,5 +46,9 @@ export const appConfig: ApplicationConfig = {
|
|||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([apiBaseInterceptor, authIntercept])),
|
||||
provideAnimationsAsync(),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -17,6 +17,7 @@ export const routes: Routes = [
|
|||
loadComponent: () =>
|
||||
import('./home/home.component').then((m) => m.HomeComponent),
|
||||
pathMatch: 'full',
|
||||
data: { title: 'Home' },
|
||||
},
|
||||
{
|
||||
path: 'talkgroups',
|
||||
|
@ -24,6 +25,7 @@ export const routes: Routes = [
|
|||
import('./talkgroups/talkgroups.component').then(
|
||||
(m) => m.TalkgroupsComponent,
|
||||
),
|
||||
data: { title: 'Talkgroups' },
|
||||
},
|
||||
{
|
||||
path: 'talkgroups/import',
|
||||
|
@ -31,6 +33,7 @@ export const routes: Routes = [
|
|||
import('./talkgroups/import/import.component').then(
|
||||
(m) => m.ImportComponent,
|
||||
),
|
||||
data: { title: 'Import Talkgroups' },
|
||||
},
|
||||
{
|
||||
path: 'talkgroups/export',
|
||||
|
@ -38,6 +41,7 @@ export const routes: Routes = [
|
|||
import('./talkgroups/export/export.component').then(
|
||||
(m) => m.ExportComponent,
|
||||
),
|
||||
data: { title: 'Export Talkgroups' },
|
||||
},
|
||||
{
|
||||
path: 'talkgroups/:sys/:tg',
|
||||
|
@ -45,11 +49,13 @@ export const routes: Routes = [
|
|||
import(
|
||||
'./talkgroups/talkgroup-record/talkgroup-record.component'
|
||||
).then((m) => m.TalkgroupRecordComponent),
|
||||
data: { title: 'Edit Talkgroup' },
|
||||
},
|
||||
{
|
||||
path: 'calls',
|
||||
loadComponent: () =>
|
||||
import('./calls/calls.component').then((m) => m.CallsComponent),
|
||||
data: { title: 'Calls' },
|
||||
},
|
||||
{
|
||||
path: 'incidents',
|
||||
|
@ -57,11 +63,13 @@ export const routes: Routes = [
|
|||
import('./incidents/incidents.component').then(
|
||||
(m) => m.IncidentsComponent,
|
||||
),
|
||||
data: { title: 'Incidents' },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
loadComponent: () =>
|
||||
import('./alerts/alerts.component').then((m) => m.AlertsComponent),
|
||||
data: { title: 'Alerts' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -5,18 +5,18 @@ import { inject } from '@angular/core';
|
|||
export const AuthGuard: CanActivateFn = (route, state) => {
|
||||
const router: Router = inject(Router);
|
||||
const authSvc: AuthService = inject(AuthService);
|
||||
if (sessionStorage.getItem('jwt') == null) {
|
||||
if (localStorage.getItem('jwt') == null) {
|
||||
let success = false;
|
||||
authSvc.refresh().subscribe(
|
||||
(event) => {
|
||||
authSvc.refresh().subscribe({
|
||||
next: (event) => {
|
||||
if (event?.status == 200) {
|
||||
success = true;
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
error: (err) => {
|
||||
router.navigate(['/login']);
|
||||
},
|
||||
);
|
||||
});
|
||||
return success;
|
||||
} else {
|
||||
return true;
|
||||
|
|
9
client/stillbox/src/app/calls.ts
Normal file
9
client/stillbox/src/app/calls.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface CallRecord {
|
||||
id: string;
|
||||
call_date: Date;
|
||||
duration: number;
|
||||
system_id: number;
|
||||
tgid: number;
|
||||
system_name: string;
|
||||
tg_name: string;
|
||||
}
|
|
@ -1 +1,181 @@
|
|||
<p>calls works!</p>
|
||||
<div [ngClass]="{ hidden: (tcSvc.filterPanel | async) }" class="toolbar">
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
|
||||
<mat-label>Start</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime-local"
|
||||
name="start"
|
||||
placeholder="Start date"
|
||||
formControlName="start"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
|
||||
<mat-label>End</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime-local"
|
||||
name="end"
|
||||
placeholder="End date"
|
||||
formControlName="end"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field subscriptSizing="dynamic" class="durationFilter">
|
||||
<mat-label>Dur (s) ></mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
name="duration"
|
||||
placeholder="Seconds"
|
||||
formControlName="duration"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="filterBox" subscriptSizing="dynamic">
|
||||
<mat-label>Filter</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="filter"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="filter"
|
||||
/>
|
||||
<button
|
||||
class="clearBtn"
|
||||
*ngIf="form.controls['filter'].value"
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
(click)="form.controls['filter'].setValue('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="tagSelect" subscriptSizing="dynamic">
|
||||
<mat-label>Any Tags</mat-label>
|
||||
<mat-select
|
||||
matNativeControl
|
||||
name="tagsAny"
|
||||
formControlName="tagsAny"
|
||||
multiple
|
||||
>
|
||||
@for (tag of tgSvc.allTags() | async; track tag) {
|
||||
<mat-option value="{{ tag }}">{{ tag }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="tagSelect" subscriptSizing="dynamic">
|
||||
<mat-label>Not Tags</mat-label>
|
||||
<mat-select
|
||||
matNativeControl
|
||||
name="tagsNot"
|
||||
class="tagSelect"
|
||||
formControlName="tagsNot"
|
||||
multiple
|
||||
>
|
||||
@for (tag of tgSvc.allTags() | async; track tag) {
|
||||
<mat-option value="{{ tag }}">{{ tag }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="toolbarButtons">
|
||||
<button class="sbButton" (click)="resetFilter()">
|
||||
<mat-icon class="material-symbols-outlined">reset_settings</mat-icon>
|
||||
</button>
|
||||
<button class="sbButton" (click)="refresh()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tabContainer" *ngIf="!isLoading; else spinner">
|
||||
<table class="callsTable" mat-table [dataSource]="callsResult">
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox
|
||||
(change)="$event ? masterToggle() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-checkbox
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="play">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
<a class="playButton" (click)="playAudio($event, call)"
|
||||
><mat-icon>play_arrow</mat-icon></a
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="download">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
<a [href]="call | audioDownloadURL"><mat-icon>download</mat-icon></a>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<th mat-header-cell *matHeaderCellDef>Date</th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
{{ call.call_date | grabDate }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="time">
|
||||
<th mat-header-cell *matHeaderCellDef>Time</th>
|
||||
<td [title]="call.call_date" mat-cell *matCellDef="let call">
|
||||
{{ call.call_date | time }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="system">
|
||||
<th mat-header-cell *matHeaderCellDef>System</th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
{{ call | talkgroup: "system" | async }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="group">
|
||||
<th mat-header-cell *matHeaderCellDef>Group</th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
{{ call | talkgroup: "group" | async }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="talkgroup">
|
||||
<th mat-header-cell *matHeaderCellDef>Talkgroup</th>
|
||||
<td mat-cell *matCellDef="let call">
|
||||
{{ call | talkgroup: "alpha" | async }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="duration">
|
||||
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
|
||||
<td mat-cell *matCellDef="let call" class="duration">
|
||||
{{ call.duration | fixedPoint: 1000 : 2 }}s
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
|
||||
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagFoot">
|
||||
<mat-paginator
|
||||
#paginator
|
||||
class="paginator"
|
||||
(page)="setPage($event)"
|
||||
[length]="count"
|
||||
showFirstLastButtons="true"
|
||||
[pageSize]="curPage.pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="curPage.pageIndex"
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</div>
|
||||
<ng-template #spinner>
|
||||
<div class="spinner">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
.timeFilterBox {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
table,
|
||||
.callsTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
max-height: calc(
|
||||
(
|
||||
100vh - var(--mat-mat-maginator-container-size, 56px) - 2 *
|
||||
(var(--mat-toolbar-standard-height, 64px))
|
||||
) + 7px
|
||||
);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.toolbarButtons button,
|
||||
.toolbarButtons form,
|
||||
.toolbarButtons form button {
|
||||
justify-content: flex-end;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.mat-mdc-row {
|
||||
height: 2.3rem !important;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.mdc-text-field__input::-webkit-calendar-picker-indicator {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.mat-column-select,
|
||||
.mat-column-time,
|
||||
.mat-column-play,
|
||||
.mat-column-download,
|
||||
.mat-column-duration {
|
||||
width: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.mat-column-date {
|
||||
width: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.mat-column-system {
|
||||
width: 4%;
|
||||
}
|
||||
|
||||
.mat-column-talkgroup {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.mat-column-group,
|
||||
.mat-column-system {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabFootContainer {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.toolbar form {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
form {
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filterBox {
|
||||
flex: 1 1 300px;
|
||||
}
|
||||
|
||||
.durationFilter {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
|
||||
.tagSelect {
|
||||
flex: 1 1 250px;
|
||||
}
|
|
@ -1,10 +1,342 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
|
||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import {
|
||||
MatPaginator,
|
||||
MatPaginatorModule,
|
||||
PageEvent,
|
||||
} from '@angular/material/paginator';
|
||||
import { PrefsService } from '../prefs/prefs.service';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { CallsListParams, CallsService } from './calls.service';
|
||||
import { CallRecord } from '../calls';
|
||||
|
||||
import { TalkgroupService } from '../talkgroups/talkgroups.service';
|
||||
import { Talkgroup } from '../talkgroup';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTimepickerModule } from '@angular/material/timepicker';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
||||
import { MatSelect, MatSelectModule } from '@angular/material/select';
|
||||
|
||||
@Pipe({
|
||||
name: 'grabDate',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class DatePipe implements PipeTransform {
|
||||
transform(ts: string, args?: any): string {
|
||||
const timestamp = new Date(ts);
|
||||
return timestamp.getMonth() + 1 + '/' + timestamp.getDate();
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'time',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class TimePipe implements PipeTransform {
|
||||
transform(ts: string, args?: any): string {
|
||||
const timestamp = new Date(ts);
|
||||
return timestamp.toLocaleTimeString(navigator.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h24',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'talkgroup',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class TalkgroupPipe implements PipeTransform {
|
||||
constructor(private tgService: TalkgroupService) {}
|
||||
|
||||
transform(call: CallRecord, field: string): Observable<string> {
|
||||
return this.tgService.getTalkgroup(call.system_id, call.tgid).pipe(
|
||||
map((tg: Talkgroup) => {
|
||||
switch (field) {
|
||||
case 'alpha': {
|
||||
return tg.alpha_tag ?? call.tgid;
|
||||
break;
|
||||
}
|
||||
case 'group': {
|
||||
return tg.tg_group ?? '\u2014';
|
||||
break;
|
||||
}
|
||||
case 'system': {
|
||||
return tg.system?.name ?? tg.system_id.toString();
|
||||
}
|
||||
default: {
|
||||
return tg.name ?? '\u2014';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'fixedPoint',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class FixedPointPipe implements PipeTransform {
|
||||
constructor() {}
|
||||
|
||||
transform(quant: number, divisor: number, places: number): string {
|
||||
const seconds = quant / divisor;
|
||||
return seconds.toFixed(places);
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'audioDownloadURL',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class DownloadURLPipe implements PipeTransform {
|
||||
constructor(private callsSvc: CallsService) {}
|
||||
|
||||
transform(call: CallRecord, args?: any): string {
|
||||
return this.callsSvc.callAudioDownloadURL(call.id);
|
||||
}
|
||||
}
|
||||
|
||||
const reqPageSize = 200;
|
||||
@Component({
|
||||
selector: 'app-calls',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
MatIconModule,
|
||||
FixedPointPipe,
|
||||
TalkgroupPipe,
|
||||
DatePipe,
|
||||
TimePipe,
|
||||
MatPaginatorModule,
|
||||
MatTableModule,
|
||||
AsyncPipe,
|
||||
DownloadURLPipe,
|
||||
MatFormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MatInputModule,
|
||||
MatDatepickerModule,
|
||||
MatTimepickerModule,
|
||||
MatCheckboxModule,
|
||||
CommonModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './calls.component.html',
|
||||
styleUrl: './calls.component.scss',
|
||||
})
|
||||
export class CallsComponent {}
|
||||
export class CallsComponent {
|
||||
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
|
||||
@ViewChild('paginator') paginator!: MatPaginator;
|
||||
count = 0;
|
||||
page = 0;
|
||||
perPage = 25;
|
||||
pageSizeOptions = [25, 50, 75, 100, 200];
|
||||
columns = [
|
||||
'select',
|
||||
'play',
|
||||
'download',
|
||||
'date',
|
||||
'time',
|
||||
'system',
|
||||
'group',
|
||||
'talkgroup',
|
||||
'duration',
|
||||
];
|
||||
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
|
||||
currentSet!: CallRecord[];
|
||||
currentServerPage = 0; // page is never 0, forces load
|
||||
isLoading = true;
|
||||
|
||||
selection = new SelectionModel<CallRecord>(true, []);
|
||||
|
||||
form = new FormGroup({
|
||||
start: new FormControl(this.lTime(new Date())),
|
||||
end: new FormControl(null),
|
||||
filter: new FormControl(''),
|
||||
duration: new FormControl(0),
|
||||
tagsAny: new FormControl<string[]>([]),
|
||||
tagsNot: new FormControl<string[]>([]),
|
||||
});
|
||||
|
||||
subscriptions = new Subscription();
|
||||
pageWindow = 0;
|
||||
fetchCalls = new BehaviorSubject<CallsListParams>(
|
||||
this.buildParams(this.curPage, this.curPage.pageIndex),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private callsSvc: CallsService,
|
||||
private prefsSvc: PrefsService,
|
||||
public tcSvc: ToolbarContextService,
|
||||
public tgSvc: TalkgroupService,
|
||||
) {
|
||||
this.tcSvc.showFilterButton();
|
||||
}
|
||||
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.curPage.pageSize;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
buildParams(p: PageEvent, serverPage: number): CallsListParams {
|
||||
const par: CallsListParams = {
|
||||
start: new Date(this.form.controls['start'].value!),
|
||||
page: serverPage,
|
||||
perPage: reqPageSize,
|
||||
end:
|
||||
this.form.controls['end'].value != null
|
||||
? new Date(this.form.controls['end'].value!)
|
||||
: null,
|
||||
tagsAny:
|
||||
(this.form.controls['tagsAny'].value?.length ?? 0 > 0)
|
||||
? this.form.controls['tagsAny'].value
|
||||
: null,
|
||||
tagsNot:
|
||||
(this.form.controls['tagsNot'].value?.length ?? 0 > 0)
|
||||
? this.form.controls['tagsNot'].value
|
||||
: null,
|
||||
dir: 'desc',
|
||||
tgFilter:
|
||||
this.form.controls['filter'].value != ''
|
||||
? this.form.controls['filter'].value
|
||||
: null,
|
||||
atLeastSeconds:
|
||||
this.form.controls['duration'].value != null &&
|
||||
this.form.controls['duration'].value > 0
|
||||
? this.form.controls['duration'].value
|
||||
: null,
|
||||
};
|
||||
|
||||
return par;
|
||||
}
|
||||
|
||||
masterToggle() {
|
||||
this.isAllSelected()
|
||||
? this.selection.clear()
|
||||
: this.callsResult.value.forEach((row) => this.selection.select(row));
|
||||
}
|
||||
|
||||
lTime(now: Date): string {
|
||||
now.setDate(new Date().getDate() - 7);
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
return now.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
playAudio(ev: Event, call: CallRecord) {
|
||||
let au = new Audio();
|
||||
au.src = this.callsSvc.callAudioURL(call.id);
|
||||
au.load();
|
||||
au.play();
|
||||
}
|
||||
|
||||
setPage(p: PageEvent, force?: boolean) {
|
||||
this.selection.clear();
|
||||
this.curPage = p;
|
||||
if (p && p!.pageSize != this.perPage) {
|
||||
this.perPage = p!.pageSize;
|
||||
this.prefsSvc.set('callsPerPage', p!.pageSize);
|
||||
}
|
||||
this.getCalls(p, force);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.selection.clear();
|
||||
this.getCalls(this.curPage, true);
|
||||
}
|
||||
|
||||
getCalls(p: PageEvent, force?: boolean) {
|
||||
const pageStart = p.pageIndex * p.pageSize;
|
||||
const serverPage = Math.floor(pageStart / reqPageSize) + 1;
|
||||
this.pageWindow = pageStart % reqPageSize;
|
||||
if (serverPage == this.currentServerPage && !force && this.currentSet) {
|
||||
this.callsResult.next(
|
||||
this.callsResult ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) : [],
|
||||
);
|
||||
} else {
|
||||
this.currentServerPage = serverPage;
|
||||
this.fetchCalls.next(this.buildParams(p, serverPage));
|
||||
}
|
||||
}
|
||||
|
||||
zeroPage(): PageEvent {
|
||||
return <PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: this.curPage.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.tcSvc.hideFilterButton();
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.form.valueChanges.pipe(debounceTime(300)).subscribe(() => {
|
||||
this.currentServerPage = 0;
|
||||
this.setPage(this.zeroPage(), true);
|
||||
});
|
||||
this.subscriptions.add(
|
||||
this.prefsSvc.get('callsPerPage').subscribe((cpp) => {
|
||||
if (cpp && cpp != this.perPage) {
|
||||
this.perPage = cpp;
|
||||
|
||||
this.setPage(<PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: cpp,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.subscriptions.add(
|
||||
this.fetchCalls
|
||||
.pipe(
|
||||
switchMap((params) => {
|
||||
return this.callsSvc.getCalls(params);
|
||||
}),
|
||||
)
|
||||
.subscribe((calls) => {
|
||||
this.isLoading = false;
|
||||
this.count = calls.count;
|
||||
this.currentSet = calls.calls;
|
||||
this.callsResult.next(
|
||||
this.currentSet
|
||||
? this.currentSet.slice(
|
||||
this.pageWindow,
|
||||
this.pageWindow + this.perPage,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resetFilter() {
|
||||
this.form.reset();
|
||||
this.form.controls['start'].setValue(this.lTime(new Date()));
|
||||
this.form.controls['duration'].setValue(0);
|
||||
}
|
||||
}
|
||||
|
|
16
client/stillbox/src/app/calls/calls.service.spec.ts
Normal file
16
client/stillbox/src/app/calls/calls.service.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CallsService } from './calls.service';
|
||||
|
||||
describe('CallsService', () => {
|
||||
let service: CallsService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(CallsService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
41
client/stillbox/src/app/calls/calls.service.ts
Normal file
41
client/stillbox/src/app/calls/calls.service.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CallRecord } from '../calls';
|
||||
import { environment } from '.././../environments/environment';
|
||||
|
||||
export interface CallsListParams {
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
tagsAny: string[] | null;
|
||||
tagsNot: string[] | null;
|
||||
dir: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
tgFilter: string | null;
|
||||
atLeastSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface CallsPaginated {
|
||||
calls: CallRecord[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CallsService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getCalls(p: CallsListParams): Observable<CallsPaginated> {
|
||||
return this.http.post<CallsPaginated>('/api/call/', p);
|
||||
}
|
||||
|
||||
callAudioURL(id: string): string {
|
||||
return environment.baseUrl + '/api/call/' + id;
|
||||
}
|
||||
|
||||
callAudioDownloadURL(id: string): string {
|
||||
return environment.baseUrl + '/api/call/' + id + '/download';
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
|||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
|||
|
||||
@Component({
|
||||
selector: 'app-incidents',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './incidents.component.html',
|
||||
styleUrl: './incidents.component.scss',
|
||||
|
|
|
@ -17,7 +17,7 @@ export class AuthService {
|
|||
private http: HttpClient,
|
||||
private _router: Router,
|
||||
) {
|
||||
let ssJWT = sessionStorage.getItem('jwt');
|
||||
let ssJWT = localStorage.getItem('jwt');
|
||||
if (ssJWT) {
|
||||
this.loggedIn = true;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export class AuthService {
|
|||
.pipe(
|
||||
tap((event) => {
|
||||
if (event.status == 200) {
|
||||
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
|
||||
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
|
||||
this.loggedIn = true;
|
||||
this._router.navigateByUrl('/home');
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export class AuthService {
|
|||
this.loggedIn = false;
|
||||
}
|
||||
});
|
||||
sessionStorage.removeItem('jwt');
|
||||
localStorage.removeItem('jwt');
|
||||
this.loggedIn = false;
|
||||
this._router.navigateByUrl('/login');
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export class AuthService {
|
|||
.pipe(
|
||||
tap((event) => {
|
||||
if (event.status == 200) {
|
||||
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
|
||||
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
|
||||
this.loggedIn = true;
|
||||
}
|
||||
}),
|
||||
|
@ -68,6 +68,6 @@ export class AuthService {
|
|||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return sessionStorage.getItem('jwt');
|
||||
return localStorage.getItem('jwt');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<button class="login" (click)="onSubmit()">Login</button>
|
||||
<button class="login sbButton" (click)="onSubmit()">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
@if (failed) {
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../login/auth.service';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { catchError, of, Subscription } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [FormsModule, MatInputModule, MatFormFieldModule],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss',
|
||||
|
@ -19,23 +18,30 @@ export class LoginComponent {
|
|||
username: string = '';
|
||||
password: string = '';
|
||||
failed: boolean = false;
|
||||
private subscriptions = new Subscription();
|
||||
|
||||
onSubmit() {
|
||||
this.failed = false;
|
||||
this.apiService
|
||||
.login(this.username, this.password)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.failed = true;
|
||||
return of(null);
|
||||
this.subscriptions.add(
|
||||
this.apiService
|
||||
.login(this.username, this.password)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.failed = true;
|
||||
return of(null);
|
||||
}),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
if (event?.status == 200) {
|
||||
this.router.navigateByUrl('/');
|
||||
} else {
|
||||
this.failed = true;
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
if (event?.status == 200) {
|
||||
this.router.navigateByUrl('/');
|
||||
} else {
|
||||
this.failed = true;
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<mat-sidenav
|
||||
#drawer
|
||||
class="sidenav"
|
||||
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||
[mode]="(isHandset$ | async) ? 'over' : 'side'"
|
||||
[opened]="(isHandset$ | async) === false"
|
||||
[attr.role]="(tcSvc.isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||
[mode]="(tcSvc.isHandset$ | async) ? 'over' : 'side'"
|
||||
[opened]="(tcSvc.isHandset$ | async) === false"
|
||||
>
|
||||
<mat-nav-list>
|
||||
@for (r of homeRoutes; track r) {
|
||||
|
@ -25,7 +25,7 @@
|
|||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
@if (isHandset$ | async) {
|
||||
@if (tcSvc.isHandset$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle sidenav"
|
||||
|
@ -34,6 +34,17 @@
|
|||
>
|
||||
<mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
|
||||
</button>
|
||||
@if (tcSvc.filterBtn | async) {
|
||||
<button
|
||||
*ngIf="showFilter"
|
||||
type="button"
|
||||
aria-label="Toggle filters"
|
||||
mat-icon-button
|
||||
(click)="toggleFilterPanel()"
|
||||
>
|
||||
<mat-icon aria-label="Filter toggle icon">tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="content">
|
||||
<router-outlet />
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Component, inject, Input, ViewChild } from '@angular/core';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { Component, Input, ViewChild } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
@ -7,7 +6,6 @@ import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav';
|
|||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
RouterModule,
|
||||
|
@ -18,6 +16,7 @@ import {
|
|||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ToolbarContextService } from './toolbar-context.service';
|
||||
|
||||
interface HomeRoute {
|
||||
name: string;
|
||||
|
@ -30,7 +29,6 @@ interface HomeRoute {
|
|||
selector: 'app-navigation',
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrl: './navigation.component.scss',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
|
@ -48,22 +46,32 @@ interface HomeRoute {
|
|||
providers: [],
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private breakpointObserver = inject(BreakpointObserver);
|
||||
@ViewChild('drawer', { static: true }) drawer!: MatDrawer;
|
||||
|
||||
toggleSubscription!: Subscription;
|
||||
isExpanded = false;
|
||||
showFilter = false;
|
||||
showFilterSub!: Subscription;
|
||||
|
||||
@Input() events!: Observable<void>;
|
||||
|
||||
constructor(public tcSvc: ToolbarContextService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.toggleSubscription = this.events.subscribe(() => {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.showFilterSub = this.tcSvc.filterBtn.subscribe((show) => {
|
||||
this.showFilter = show;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.toggleSubscription.unsubscribe();
|
||||
this.showFilterSub.unsubscribe();
|
||||
}
|
||||
|
||||
homeRoutes = <HomeRoute[]>[
|
||||
|
@ -73,16 +81,16 @@ export class NavigationComponent {
|
|||
icon: 'home',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
name: 'Talkgroups',
|
||||
url: '/talkgroups',
|
||||
icon: 'forum',
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
url: '/calls',
|
||||
icon: 'campaign',
|
||||
},
|
||||
{
|
||||
name: 'Talkgroups',
|
||||
url: '/talkgroups',
|
||||
icon: 'forum',
|
||||
},
|
||||
{
|
||||
name: 'Incidents',
|
||||
url: '/incidents',
|
||||
|
@ -95,10 +103,7 @@ export class NavigationComponent {
|
|||
},
|
||||
];
|
||||
|
||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||
.observe(Breakpoints.Handset)
|
||||
.pipe(
|
||||
map((result) => result.matches),
|
||||
shareReplay(),
|
||||
);
|
||||
toggleFilterPanel() {
|
||||
this.tcSvc.filterPanel.next(!this.tcSvc.filterPanel.getValue());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ToolbarContextService } from './toolbar-context.service';
|
||||
|
||||
describe('ToolbarContextService', () => {
|
||||
let service: ToolbarContextService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ToolbarContextService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ToolbarContextService {
|
||||
private breakpointObserver = inject(BreakpointObserver);
|
||||
filterBtn: BehaviorSubject<boolean>;
|
||||
isHandset$: Observable<boolean>;
|
||||
filterPanel: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
subscriptions = new Subscription();
|
||||
|
||||
showFilterButton() {
|
||||
this.filterBtn.next(true);
|
||||
}
|
||||
|
||||
hideFilterButton() {
|
||||
this.filterBtn.next(false);
|
||||
}
|
||||
|
||||
showFilterPanel() {
|
||||
this.filterPanel.next(true);
|
||||
}
|
||||
|
||||
hideFilterPanel() {
|
||||
this.filterPanel.next(false);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.filterBtn = new BehaviorSubject(false);
|
||||
this.isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset).pipe(
|
||||
map((result) => result.matches),
|
||||
shareReplay(),
|
||||
);
|
||||
this.subscriptions.add(this.isHandset$.subscribe(this.filterPanel));
|
||||
}
|
||||
}
|
|
@ -1,42 +1,95 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, ReplaySubject } from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
share,
|
||||
shareReplay,
|
||||
Subscription,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
|
||||
export interface Preferences {
|
||||
tgsPerPage: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function mapToObj(map: Map<any, any>): { [key: string]: any } {
|
||||
const obj: { [key: string]: any } = {};
|
||||
map.forEach((value, key) => {
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PrefsService {
|
||||
private readonly _getPref = new Map<string, ReplaySubject<any>>();
|
||||
prefs$: Observable<Preferences>;
|
||||
last!: Preferences;
|
||||
subscriptions = new Subscription();
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.fetch();
|
||||
}
|
||||
last = <Preferences>{};
|
||||
private prefs = new ReplaySubject<Preferences>(1);
|
||||
prefs$: Observable<Preferences> = this.prefs.asObservable();
|
||||
|
||||
fetch() {
|
||||
this.http.get<Preferences>('/api/user/prefs/stillbox').subscribe((res) => {
|
||||
if (res != null) {
|
||||
this.last = res;
|
||||
}
|
||||
this.prefs.next(res);
|
||||
});
|
||||
this.prefs$ = this.fetch().pipe(shareReplay(1));
|
||||
this.fillPrefs();
|
||||
}
|
||||
|
||||
setTGsPerPage(pp: number) {
|
||||
if (this.last.tgsPerPage != pp) {
|
||||
this.last.tgsPerPage = pp;
|
||||
this.setPrefs(this.last);
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
fillPrefs() {
|
||||
this.subscriptions.add(
|
||||
this.prefs$.subscribe((prefs) => {
|
||||
if (prefs) {
|
||||
this.last = prefs;
|
||||
Object.entries(prefs).forEach((pref) => {
|
||||
const rs = this._getPref.get(pref[0]);
|
||||
if (rs) {
|
||||
(rs as ReplaySubject<any>).next(pref[1]);
|
||||
} else {
|
||||
const bs = new ReplaySubject<any>(1);
|
||||
bs.next(pref[1]);
|
||||
this._getPref.set(pref[0], bs);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.last = {};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fetch(): Observable<Preferences> {
|
||||
return this.http.get<Preferences>('/api/user/prefs/stillbox');
|
||||
}
|
||||
|
||||
get(k: string): Observable<any> {
|
||||
if (!this._getPref.get(k)) {
|
||||
return this.prefs$.pipe(
|
||||
switchMap((pref) => {
|
||||
let ns = new ReplaySubject<any>(1);
|
||||
ns.next(pref ? pref[k] : null);
|
||||
return ns;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this._getPref.get(k)!;
|
||||
}
|
||||
|
||||
setPrefs(p: Preferences) {
|
||||
set(pref: string, value: any) {
|
||||
this.last[pref] = value;
|
||||
let ex = this._getPref.get(pref);
|
||||
if (!ex) {
|
||||
ex = new ReplaySubject<any>(1);
|
||||
this._getPref.set(pref, ex);
|
||||
}
|
||||
this.http
|
||||
.put<Preferences>('/api/user/prefs/stillbox', p)
|
||||
.subscribe((event) => {});
|
||||
this.prefs.next(p);
|
||||
.put<Preferences>('/api/user/prefs/stillbox', this.last)
|
||||
.subscribe((ev) => {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import { Component, inject, Pipe, PipeTransform } from '@angular/core';
|
|||
|
||||
@Component({
|
||||
selector: 'app-export',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FormsModule],
|
||||
templateUrl: './export.component.html',
|
||||
styleUrl: './export.component.scss',
|
||||
|
|
|
@ -13,7 +13,6 @@ import { catchError, of } from 'rxjs';
|
|||
|
||||
@Component({
|
||||
selector: 'app-import',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, FormsModule],
|
||||
templateUrl: './import.component.html',
|
||||
styleUrl: './import.component.scss',
|
||||
|
|
|
@ -3,7 +3,6 @@ import { AlertRule } from '../../../talkgroup';
|
|||
|
||||
@Component({
|
||||
selector: 'alert-rule-builder',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './alert-rule-builder.component.html',
|
||||
styleUrl: './alert-rule-builder.component.scss',
|
||||
|
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
model,
|
||||
ChangeDetectionStrategy,
|
||||
Signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { Component, computed, inject, ViewChild } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import {
|
||||
|
@ -25,7 +17,7 @@ import {
|
|||
MatAutocompleteActivatedEvent,
|
||||
} from '@angular/material/autocomplete';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { BehaviorSubject, catchError, of, Subscription } from 'rxjs';
|
||||
import { shareReplay } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
|
@ -34,7 +26,7 @@ import {
|
|||
FormControl,
|
||||
FormsModule,
|
||||
} from '@angular/forms';
|
||||
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
|
@ -43,7 +35,6 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
|
||||
@Component({
|
||||
selector: 'talkgroup-record',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
|
@ -58,7 +49,6 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
],
|
||||
templateUrl: './talkgroup-record.component.html',
|
||||
styleUrl: './talkgroup-record.component.scss',
|
||||
// changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TalkgroupRecordComponent {
|
||||
tg!: Talkgroup;
|
||||
|
@ -90,6 +80,7 @@ export class TalkgroupRecordComponent {
|
|||
);
|
||||
@ViewChild('auto') autocomp!: MatAutocomplete;
|
||||
active: string | null = null;
|
||||
subscriptions = new Subscription();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
|
@ -125,7 +116,6 @@ export class TalkgroupRecordComponent {
|
|||
}
|
||||
|
||||
activated(event: MatAutocompleteActivatedEvent) {
|
||||
console.log('activated');
|
||||
this.active = event.option?.value;
|
||||
}
|
||||
|
||||
|
@ -147,31 +137,35 @@ export class TalkgroupRecordComponent {
|
|||
this.form.controls['tagInput'].reset();
|
||||
}
|
||||
|
||||
loadTags() {
|
||||
this._allTags.subscribe((event) => {
|
||||
this.allTags = event;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadTags();
|
||||
const sysId = this.route.snapshot.paramMap.get('sys');
|
||||
const tgId = this.route.snapshot.paramMap.get('tg');
|
||||
|
||||
this.tgService
|
||||
.getTalkgroup(Number(sysId), Number(tgId))
|
||||
.subscribe((data: Talkgroup) => {
|
||||
this.tg = data;
|
||||
this.form.controls['name'].setValue(this.tg.name);
|
||||
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
|
||||
this.form.controls['tg_group'].setValue(this.tg.tg_group);
|
||||
this.form.controls['frequency'].setValue(this.tg.frequency);
|
||||
this.form.controls['alert'].setValue(this.tg.alert);
|
||||
this.form.controls['weight'].setValue(this.tg.weight);
|
||||
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
|
||||
this.form.controls['tagInput'].setValue('');
|
||||
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
|
||||
});
|
||||
this.subscriptions.add(
|
||||
this.tgService
|
||||
.getTalkgroup(Number(sysId), Number(tgId))
|
||||
.subscribe((data: Talkgroup) => {
|
||||
this.tg = data;
|
||||
this.form.controls['name'].setValue(this.tg.name);
|
||||
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
|
||||
this.form.controls['tg_group'].setValue(this.tg.tg_group);
|
||||
this.form.controls['frequency'].setValue(this.tg.frequency);
|
||||
this.form.controls['alert'].setValue(this.tg.alert);
|
||||
this.form.controls['weight'].setValue(this.tg.weight);
|
||||
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
|
||||
this.form.controls['tagInput'].setValue('');
|
||||
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
|
||||
}),
|
||||
);
|
||||
this.subscriptions.add(
|
||||
this._allTags.subscribe((event) => {
|
||||
this.allTags = event;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
submit() {
|
||||
|
|
|
@ -73,18 +73,18 @@
|
|||
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
|
||||
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
|
||||
</table>
|
||||
<div class="pagFoot">
|
||||
<mat-paginator
|
||||
#paginator
|
||||
class="paginator"
|
||||
(page)="setPage($event)"
|
||||
[length]="count"
|
||||
showFirstLastButtons="true"
|
||||
[pageSize]="perPage"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="page"
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagFoot">
|
||||
<mat-paginator
|
||||
#paginator
|
||||
class="paginator"
|
||||
(page)="setPage($event)"
|
||||
[length]="count"
|
||||
showFirstLastButtons="true"
|
||||
[pageSize]="perPage"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="page"
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</div>
|
||||
|
|
|
@ -6,6 +6,13 @@ table {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(
|
||||
(
|
||||
100vh - var(--mat-mat-maginator-container-size, 56px) - 2 *
|
||||
(var(--mat-toolbar-standard-height, 64px))
|
||||
) + 7px
|
||||
);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
mat-icon.tgIcon {
|
||||
|
|
|
@ -57,7 +57,6 @@ export class SanitizeHtmlPipe implements PipeTransform {
|
|||
|
||||
@Component({
|
||||
selector: 'talkgroup-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterModule,
|
||||
RouterLink,
|
||||
|
@ -84,7 +83,7 @@ export class TalkgroupTableComponent {
|
|||
talkgroups = input<TalkgroupsPaginated>();
|
||||
talkgroups$ = toObservable(this.talkgroups);
|
||||
dataSource = new MatTableDataSource<Talkgroup>();
|
||||
pageSizeOptions = [25, 50, 75, 100, 200, 500];
|
||||
pageSizeOptions = [25, 50, 75, 100, 200];
|
||||
perPage: number = 25;
|
||||
count = 0;
|
||||
columns = [
|
||||
|
@ -124,7 +123,9 @@ export class TalkgroupTableComponent {
|
|||
this.suppress = false;
|
||||
});
|
||||
|
||||
this.perPage = this.prefsService.last.tgsPerPage;
|
||||
this.prefsService.get('tgsPerPage').subscribe((tgpp) => {
|
||||
this.perPage = tgpp;
|
||||
});
|
||||
this.talkgroups$.subscribe((event) => {
|
||||
if (event != null) {
|
||||
this.dataSource.data = event!.talkgroups;
|
||||
|
|
|
@ -33,9 +33,3 @@ talkgroups {
|
|||
margin-left: auto;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
margin-top: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TalkgroupService, TalkgroupsPaginated } from './talkgroups.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||
|
@ -8,16 +8,15 @@ import { TalkgroupTableComponent } from './talkgroup-table/talkgroup-table.compo
|
|||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { PrefsService } from '../prefs/prefs.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatInputModule, MatInput } from '@angular/material/input';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'talkgroups',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterModule,
|
||||
RouterLink,
|
||||
|
@ -41,10 +40,12 @@ export class TalkgroupsComponent {
|
|||
tgs!: TalkgroupsPaginated;
|
||||
tgService: TalkgroupService = inject(TalkgroupService);
|
||||
prefsService: PrefsService = inject(PrefsService);
|
||||
perPage = 25;
|
||||
perPage = 0;
|
||||
filter = new FormControl('');
|
||||
curPage = <PageEvent>{ pageIndex: 0, pageSize: this.perPage };
|
||||
private getPage = new ReplaySubject<PageEvent>(1);
|
||||
constructor(private route: ActivatedRoute) {}
|
||||
subs = new Subscription();
|
||||
|
||||
pageReset: Subject<void> = new Subject<void>();
|
||||
|
||||
|
@ -54,30 +55,60 @@ export class TalkgroupsComponent {
|
|||
|
||||
switchPage(p: PageEvent) {
|
||||
this.curPage = p;
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
switchMap((params) => {
|
||||
this.perPage = p!.pageSize;
|
||||
this.selectedSys = Number(params.get('sys'));
|
||||
this.selectedId = Number(params.get('tg'));
|
||||
return this.tgService.getTalkgroupsPag({
|
||||
page: p!.pageIndex + 1,
|
||||
perPage: p!.pageSize,
|
||||
filter: this.filter.value == '' ? null : this.filter.value,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.subscribe((ev) => {
|
||||
this.tgs = ev;
|
||||
});
|
||||
this.prefsService.setTGsPerPage(p!.pageSize);
|
||||
|
||||
if (p.pageSize != this.perPage) {
|
||||
this.perPage = p.pageSize;
|
||||
this.prefsService.set('tgsPerPage', this.perPage);
|
||||
}
|
||||
this.getPage.next(p);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.prefsService.prefs$.subscribe((event) => {
|
||||
this.perPage = event?.tgsPerPage ?? 25;
|
||||
this.switchPage(this.curPage);
|
||||
this.prefsService.get('tgsPerPage').subscribe((tgpp) => {
|
||||
if (!tgpp) {
|
||||
tgpp = 25;
|
||||
}
|
||||
if (tgpp != this.perPage) {
|
||||
this.perPage = tgpp;
|
||||
|
||||
this.switchPage(<PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: tgpp,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.subs.add(
|
||||
this.getPage
|
||||
.pipe(
|
||||
switchMap((p) =>
|
||||
this.route.paramMap.pipe(
|
||||
switchMap((params) => {
|
||||
this.selectedSys = Number(params.get('sys'));
|
||||
this.selectedId = Number(params.get('tg'));
|
||||
return this.tgService.getTalkgroupsPag({
|
||||
page: p.pageIndex + 1,
|
||||
perPage: p.pageSize,
|
||||
filter: this.filter.value == '' ? null : this.filter.value,
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe((ev) => {
|
||||
this.tgs = ev;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
zeroPage(): PageEvent {
|
||||
return <PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: this.curPage.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpResponse } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Talkgroup, TalkgroupUpdate } from '../talkgroup';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
shareReplay,
|
||||
Subscription,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { Talkgroup, TalkgroupUpdate, TGID } from '../talkgroup';
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
|
@ -18,18 +26,94 @@ export interface TalkgroupsPaginated {
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class TalkgroupService {
|
||||
constructor(private http: HttpClient) {}
|
||||
private readonly _getTalkgroup = new Map<string, ReplaySubject<Talkgroup>>();
|
||||
private tgs$: Observable<Talkgroup[]>;
|
||||
private tags$!: Observable<string[]>;
|
||||
private fetchAll = new BehaviorSubject<'fetch'>('fetch');
|
||||
private subscriptions = new Subscription();
|
||||
constructor(private http: HttpClient) {
|
||||
this.tgs$ = this.fetchAll.pipe(switchMap(() => this.getTalkgroups()));
|
||||
this.tags$ = this.fetchAll.pipe(switchMap(() => this.getAllTags()));
|
||||
this.fillTgMap();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
getAllTags(): Observable<string[]> {
|
||||
return this.http.get<string[]>('/api/talkgroup/tags').pipe(shareReplay());
|
||||
}
|
||||
|
||||
getTalkgroups(): Observable<Talkgroup[]> {
|
||||
return this.http.get<Talkgroup[]>('/api/talkgroup/');
|
||||
}
|
||||
|
||||
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
|
||||
const key = this.tgKey(sys, tg);
|
||||
if (!this._getTalkgroup.get(key)) {
|
||||
return this.tgs$.pipe(
|
||||
switchMap((talkg) =>
|
||||
talkg.filter((tgv) => tgv.tgid == tg && tgv.system_id == sys),
|
||||
),
|
||||
);
|
||||
}
|
||||
return this._getTalkgroup.get(key)!;
|
||||
}
|
||||
|
||||
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
|
||||
let tgid = this.tgKey(tu.system_id, tu.tgid);
|
||||
|
||||
this.http
|
||||
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
|
||||
.pipe(
|
||||
switchMap((tg) => {
|
||||
let tObs = this._getTalkgroup.get(tgid);
|
||||
if (!tObs) {
|
||||
tObs = new ReplaySubject<Talkgroup>(1);
|
||||
this._getTalkgroup.set(tgid, tObs);
|
||||
}
|
||||
|
||||
tObs.next(tg);
|
||||
return tObs;
|
||||
}),
|
||||
);
|
||||
|
||||
return this._getTalkgroup.get(tgid)!;
|
||||
}
|
||||
|
||||
putTalkgroups(
|
||||
sysID: Number,
|
||||
tgs: TalkgroupUpdate[],
|
||||
): Observable<Talkgroup[]> {
|
||||
this._getTalkgroup.clear();
|
||||
return this.http.put<Talkgroup[]>(`/api/talkgroup/${sysID}`, tgs);
|
||||
}
|
||||
|
||||
tgKey(sys: number, tg: number): string {
|
||||
return sys + ':' + tg;
|
||||
}
|
||||
|
||||
getTalkgroupsPag(pagination: Pagination): Observable<TalkgroupsPaginated> {
|
||||
return this.http.post<TalkgroupsPaginated>('/api/talkgroup/', pagination);
|
||||
}
|
||||
|
||||
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
|
||||
return this.http.get<Talkgroup>(`/api/talkgroup/${sys}/${tg}`);
|
||||
fillTgMap() {
|
||||
this.subscriptions.add(
|
||||
this.tgs$.subscribe((tgs) => {
|
||||
tgs.forEach((tg) => {
|
||||
let tgid = this.tgKey(tg.system_id, tg.tgid);
|
||||
const rs = this._getTalkgroup.get(tgid);
|
||||
if (rs) {
|
||||
(rs as ReplaySubject<Talkgroup>).next(tg);
|
||||
} else {
|
||||
const bs = new ReplaySubject<Talkgroup>(1);
|
||||
bs.next(tg);
|
||||
this._getTalkgroup.set(tgid, bs);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
importRR(sysID: number, content: string): Observable<Talkgroup[]> {
|
||||
|
@ -41,7 +125,7 @@ export class TalkgroupService {
|
|||
}
|
||||
|
||||
allTags(): Observable<string[]> {
|
||||
return this.http.get<string[]>('/api/talkgroup/tags');
|
||||
return this.tags$;
|
||||
}
|
||||
|
||||
exportTGs(
|
||||
|
@ -58,18 +142,4 @@ export class TalkgroupService {
|
|||
responseType: 'blob' as 'json',
|
||||
});
|
||||
}
|
||||
|
||||
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
|
||||
return this.http.put<Talkgroup>(
|
||||
`/api/talkgroup/${tu.system_id}/${tu.tgid}`,
|
||||
tu,
|
||||
);
|
||||
}
|
||||
|
||||
putTalkgroups(
|
||||
sysID: Number,
|
||||
tgs: TalkgroupUpdate[],
|
||||
): Observable<Talkgroup[]> {
|
||||
return this.http.put<Talkgroup[]>(`/api/talkgroup/${sysID}`, tgs);
|
||||
}
|
||||
}
|
||||
|
|
16
client/stillbox/src/app/version/checker.service.spec.ts
Normal file
16
client/stillbox/src/app/version/checker.service.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CheckerService } from './checker.service';
|
||||
|
||||
describe('CheckerService', () => {
|
||||
let service: CheckerService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(CheckerService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
44
client/stillbox/src/app/version/checker.service.ts
Normal file
44
client/stillbox/src/app/version/checker.service.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { SwUpdate } from '@angular/service-worker';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CheckerService {
|
||||
updateAvailable = false;
|
||||
versionSub!: Subscription;
|
||||
|
||||
doUpdate() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
constructor(private swUpd: SwUpdate) {
|
||||
this.checkUpdate();
|
||||
}
|
||||
|
||||
checkUpdate() {
|
||||
this.versionSub?.unsubscribe();
|
||||
if (!this.swUpd.isEnabled) {
|
||||
return;
|
||||
}
|
||||
this.versionSub = this.swUpd.versionUpdates.subscribe((evt) => {
|
||||
switch (evt.type) {
|
||||
case 'VERSION_DETECTED':
|
||||
console.log('Detected new version ${evt.version.hash}');
|
||||
break;
|
||||
case 'VERSION_READY':
|
||||
console.log(`Current app version: ${evt.currentVersion.hash}`);
|
||||
console.log(
|
||||
`New app version ready for use: ${evt.latestVersion.hash}`,
|
||||
);
|
||||
this.updateAvailable = true;
|
||||
break;
|
||||
case 'VERSION_INSTALLATION_FAILED':
|
||||
console.log(
|
||||
`Failed to install app version '${evt.version.hash}': ${evt.error}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
@if (checkerSvc.updateAvailable) {
|
||||
<div class="updater" (click)="update()">
|
||||
<p>New version available!</p>
|
||||
<button class="sbButton" (click)="checkerSvc.doUpdate()">Update</button>
|
||||
</div>
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
@media screen and (max-width: 768px) {
|
||||
.updater {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media not screen and (max-width: 768px) {
|
||||
.updater {
|
||||
width: 400px;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.updater {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #311818;
|
||||
}
|
||||
|
||||
.updater p {
|
||||
flex: 2 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.updater button {
|
||||
flex: 0 0 100px;
|
||||
justify-content: flex-end;
|
||||
margin-right: 10px;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { UpdateNagComponent } from './update-nag.component';
|
||||
|
||||
describe('UpdateNagComponent', () => {
|
||||
let component: UpdateNagComponent;
|
||||
let fixture: ComponentFixture<UpdateNagComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UpdateNagComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UpdateNagComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { CheckerService } from '../checker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-update-nag',
|
||||
imports: [],
|
||||
templateUrl: './update-nag.component.html',
|
||||
styleUrl: './update-nag.component.scss',
|
||||
})
|
||||
export class UpdateNagComponent {
|
||||
constructor(public checkerSvc: CheckerService) {}
|
||||
|
||||
update() {
|
||||
this.checkerSvc.doUpdate();
|
||||
}
|
||||
}
|
BIN
client/stillbox/src/assets/MatSymOutline.ttf
Normal file
BIN
client/stillbox/src/assets/MatSymOutline.ttf
Normal file
Binary file not shown.
|
@ -2,12 +2,17 @@
|
|||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Admin</title>
|
||||
<title>Stillbox</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#1976d2" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<noscript
|
||||
>Please enable JavaScript to continue using this application.</noscript
|
||||
>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -6,32 +6,33 @@
|
|||
// Include the common styles for Angular Material. We include this here so that you only
|
||||
// have to load a single css file for Angular Material in your app.
|
||||
// Be sure that you only ever include this mixin once!
|
||||
@include mat.core();
|
||||
@include mat.elevation-classes();
|
||||
@include mat.app-background();
|
||||
|
||||
// Define the palettes for your theme using the Material Design palettes available in palette.scss
|
||||
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
|
||||
// hue. Available color palettes: https://material.io/design/color/
|
||||
$stillbox-primary: mat.define-palette(mat.$grey-palette, A700);
|
||||
$stillbox-accent: mat.define-palette(
|
||||
mat.$deep-purple-palette,
|
||||
$stillbox-primary: mat.m2-define-palette(mat.$m2-grey-palette, A700);
|
||||
$stillbox-accent: mat.m2-define-palette(
|
||||
mat.$m2-deep-purple-palette,
|
||||
A200,
|
||||
A100,
|
||||
A400
|
||||
);
|
||||
|
||||
// The warn palette is optional (defaults to red).
|
||||
$stillbox-warn: mat.define-palette(mat.$red-palette);
|
||||
$stillbox-warn: mat.m2-define-palette(mat.$m2-red-palette);
|
||||
|
||||
// Create the theme object. A theme consists of configurations for individual
|
||||
// theming systems such as "color" or "typography".
|
||||
$stillbox-theme: mat.define-dark-theme(
|
||||
$stillbox-theme: mat.m2-define-dark-theme(
|
||||
(
|
||||
color: (
|
||||
primary: $stillbox-primary,
|
||||
accent: $stillbox-accent,
|
||||
warn: $stillbox-warn,
|
||||
),
|
||||
typography: mat.define-typography-config(),
|
||||
typography: mat.m2-define-typography-config(),
|
||||
density: 0,
|
||||
)
|
||||
);
|
||||
|
@ -107,6 +108,27 @@ html {
|
|||
direction: ltr;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("./assets/MatSymOutline.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
text-decoration: none;
|
||||
|
@ -203,3 +225,13 @@ body {
|
|||
input {
|
||||
caret-color: var(--color-dark-fg) !important;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
margin-top: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
|
@ -18,6 +18,10 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "stillboxJwt"
|
||||
)
|
||||
|
||||
type jwtAuth interface {
|
||||
// Authenticated returns whether the request is authenticated. It also returns the claims.
|
||||
Authenticated(r *http.Request) (claims, bool)
|
||||
|
@ -67,7 +71,7 @@ func (a *Auth) VerifyMiddleware() func(http.Handler) http.Handler {
|
|||
return func(next http.Handler) http.Handler {
|
||||
hfn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token, err := jwtauth.VerifyRequest(a.jwt, r, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie)
|
||||
token, err := jwtauth.VerifyRequest(a.jwt, r, jwtauth.TokenFromHeader, TokenFromCookie)
|
||||
ctx = jwtauth.NewContext(ctx, token, err)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
@ -75,6 +79,14 @@ func (a *Auth) VerifyMiddleware() func(http.Handler) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
func TokenFromCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return jwtauth.Authenticator(a.jwt)
|
||||
}
|
||||
|
@ -154,7 +166,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
|||
tok := a.newToken(uid)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: "jwt",
|
||||
Name: CookieName,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
|
@ -167,7 +179,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if cookie.Secure {
|
||||
cookie.Domain = a.cfg.Domain
|
||||
cookie.Domain = r.Host
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
|
@ -215,18 +227,17 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "jwt",
|
||||
Name: CookieName,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
}
|
||||
|
||||
cookie.Domain = r.Host
|
||||
if a.allowInsecureCookie(r) {
|
||||
cookie.Secure = false
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
} else {
|
||||
cookie.Domain = a.cfg.Domain
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
|
@ -242,7 +253,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "jwt",
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
|
@ -250,11 +261,10 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
|
|||
MaxAge: -1,
|
||||
}
|
||||
|
||||
cookie.Domain = r.Host
|
||||
if a.allowInsecureCookie(r) {
|
||||
cookie.Secure = false
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
} else {
|
||||
cookie.Domain = a.cfg.Domain
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
|
|
|
@ -2,6 +2,7 @@ package callstore
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dynatron.me/x/stillbox/internal/common"
|
||||
"dynatron.me/x/stillbox/internal/jsontypes"
|
||||
|
@ -11,6 +12,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
|
@ -65,10 +67,12 @@ type CallsParams struct {
|
|||
common.Pagination
|
||||
Direction *common.SortDirection `json:"dir"`
|
||||
|
||||
Start *jsontypes.Time `json:"start"`
|
||||
End *jsontypes.Time `json:"end"`
|
||||
TagsAny []string `json:"tagsAny"`
|
||||
TagsNot []string `json:"tagsNot"`
|
||||
Start *jsontypes.Time `json:"start"`
|
||||
End *jsontypes.Time `json:"end"`
|
||||
TagsAny []string `json:"tagsAny"`
|
||||
TagsNot []string `json:"tagsNot"`
|
||||
TGFilter *string `json:"tgFilter"`
|
||||
AtLeastSeconds *float32 `json:"atLeastSeconds"`
|
||||
}
|
||||
|
||||
func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
|
||||
|
@ -83,16 +87,27 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
|
|||
Offset: offset,
|
||||
PerPage: perPage,
|
||||
Direction: p.Direction.DirString(common.DirAsc),
|
||||
TGFilter: p.TGFilter,
|
||||
}
|
||||
|
||||
if p.AtLeastSeconds != nil {
|
||||
var n pgtype.Numeric
|
||||
if err := n.Scan(fmt.Sprint(*p.AtLeastSeconds * 1000)); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
par.LongerThan = n
|
||||
}
|
||||
|
||||
var count int64
|
||||
txErr := db.InTx(ctx, func(db database.Store) error {
|
||||
var err error
|
||||
count, err = db.ListCallsCount(ctx, database.ListCallsCountParams{
|
||||
Start: par.Start,
|
||||
End: par.End,
|
||||
TagsAny: par.TagsAny,
|
||||
TagsNot: par.TagsNot,
|
||||
Start: par.Start,
|
||||
End: par.End,
|
||||
TagsAny: par.TagsAny,
|
||||
TagsNot: par.TagsNot,
|
||||
TGFilter: par.TGFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -30,7 +30,6 @@ type Config struct {
|
|||
|
||||
type Auth struct {
|
||||
JWTSecret string `yaml:"jwtsecret"`
|
||||
Domain string `yaml:"domain"`
|
||||
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,6 @@ var expCfg = &Config{
|
|||
},
|
||||
Auth: Auth{
|
||||
JWTSecret: "somesecret",
|
||||
Domain: "xenon",
|
||||
AllowInsecure: map[string]bool{
|
||||
"localhost": true,
|
||||
"stillbox": true,
|
||||
|
|
|
@ -203,14 +203,24 @@ CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
|||
CASE WHEN $3::TEXT[] IS NOT NULL THEN
|
||||
tgs.tags @> ARRAY[$3] ELSE TRUE END AND
|
||||
CASE WHEN $4::TEXT[] IS NOT NULL THEN
|
||||
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END
|
||||
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END AND
|
||||
(CASE WHEN $5::TEXT IS NOT NULL THEN (
|
||||
tgs.tg_group ILIKE '%' || $5 || '%' OR
|
||||
tgs.name ILIKE '%' || $5 || '%' OR
|
||||
tgs.alpha_tag ILIKE '%' || $5 || '%'
|
||||
) ELSE TRUE END) AND
|
||||
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
||||
c.duration > $6
|
||||
) ELSE TRUE END)
|
||||
`
|
||||
|
||||
type ListCallsCountParams struct {
|
||||
Start pgtype.Timestamptz `json:"start"`
|
||||
End pgtype.Timestamptz `json:"end"`
|
||||
TagsAny []string `json:"tags_any"`
|
||||
TagsNot []string `json:"tags_not"`
|
||||
Start pgtype.Timestamptz `json:"start"`
|
||||
End pgtype.Timestamptz `json:"end"`
|
||||
TagsAny []string `json:"tags_any"`
|
||||
TagsNot []string `json:"tags_not"`
|
||||
TGFilter *string `json:"tg_filter"`
|
||||
LongerThan pgtype.Numeric `json:"longer_than"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
|
||||
|
@ -219,6 +229,8 @@ func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams)
|
|||
arg.End,
|
||||
arg.TagsAny,
|
||||
arg.TagsNot,
|
||||
arg.TGFilter,
|
||||
arg.LongerThan,
|
||||
)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
|
@ -245,22 +257,32 @@ CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
|||
CASE WHEN $3::TEXT[] IS NOT NULL THEN
|
||||
tgs.tags @> ARRAY[$3] ELSE TRUE END AND
|
||||
CASE WHEN $4::TEXT[] IS NOT NULL THEN
|
||||
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END
|
||||
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END AND
|
||||
(CASE WHEN $5::TEXT IS NOT NULL THEN (
|
||||
tgs.tg_group ILIKE '%' || $5 || '%' OR
|
||||
tgs.name ILIKE '%' || $5 || '%' OR
|
||||
tgs.alpha_tag ILIKE '%' || $5 || '%'
|
||||
) ELSE TRUE END) AND
|
||||
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
||||
c.duration > $6
|
||||
) ELSE TRUE END)
|
||||
ORDER BY
|
||||
CASE WHEN $5::TEXT = 'asc' THEN c.call_date END ASC,
|
||||
CASE WHEN $5 = 'desc' THEN c.call_date END DESC
|
||||
OFFSET $6 ROWS
|
||||
FETCH NEXT $7 ROWS ONLY
|
||||
CASE WHEN $7::TEXT = 'asc' THEN c.call_date END ASC,
|
||||
CASE WHEN $7 = 'desc' THEN c.call_date END DESC
|
||||
OFFSET $8 ROWS
|
||||
FETCH NEXT $9 ROWS ONLY
|
||||
`
|
||||
|
||||
type ListCallsPParams struct {
|
||||
Start pgtype.Timestamptz `json:"start"`
|
||||
End pgtype.Timestamptz `json:"end"`
|
||||
TagsAny []string `json:"tags_any"`
|
||||
TagsNot []string `json:"tags_not"`
|
||||
Direction string `json:"direction"`
|
||||
Offset int32 `json:"offset"`
|
||||
PerPage int32 `json:"per_page"`
|
||||
Start pgtype.Timestamptz `json:"start"`
|
||||
End pgtype.Timestamptz `json:"end"`
|
||||
TagsAny []string `json:"tags_any"`
|
||||
TagsNot []string `json:"tags_not"`
|
||||
TGFilter *string `json:"tg_filter"`
|
||||
LongerThan pgtype.Numeric `json:"longer_than"`
|
||||
Direction string `json:"direction"`
|
||||
Offset int32 `json:"offset"`
|
||||
PerPage int32 `json:"per_page"`
|
||||
}
|
||||
|
||||
type ListCallsPRow struct {
|
||||
|
@ -279,6 +301,8 @@ func (q *Queries) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListC
|
|||
arg.End,
|
||||
arg.TagsAny,
|
||||
arg.TagsNot,
|
||||
arg.TGFilter,
|
||||
arg.LongerThan,
|
||||
arg.Direction,
|
||||
arg.Offset,
|
||||
arg.PerPage,
|
||||
|
|
|
@ -33,7 +33,7 @@ func (ca *callsAPI) Subrouter() http.Handler {
|
|||
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio)
|
||||
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio)
|
||||
|
||||
r.Post(`/list`, ca.listCalls)
|
||||
r.Post(`/`, ca.listCalls)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
@ -101,7 +101,15 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
|||
CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN
|
||||
tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND
|
||||
CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
|
||||
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END
|
||||
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END AND
|
||||
(CASE WHEN sqlc.narg('tg_filter')::TEXT IS NOT NULL THEN (
|
||||
tgs.tg_group ILIKE '%' || @tg_filter || '%' OR
|
||||
tgs.name ILIKE '%' || @tg_filter || '%' OR
|
||||
tgs.alpha_tag ILIKE '%' || @tg_filter || '%'
|
||||
) ELSE TRUE END) AND
|
||||
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
||||
c.duration > @longer_than
|
||||
) ELSE TRUE END)
|
||||
ORDER BY
|
||||
CASE WHEN @direction::TEXT = 'asc' THEN c.call_date END ASC,
|
||||
CASE WHEN @direction = 'desc' THEN c.call_date END DESC
|
||||
|
@ -122,5 +130,13 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
|||
CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN
|
||||
tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND
|
||||
CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
|
||||
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END
|
||||
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END AND
|
||||
(CASE WHEN sqlc.narg('tg_filter')::TEXT IS NOT NULL THEN (
|
||||
tgs.tg_group ILIKE '%' || @tg_filter || '%' OR
|
||||
tgs.name ILIKE '%' || @tg_filter || '%' OR
|
||||
tgs.alpha_tag ILIKE '%' || @tg_filter || '%'
|
||||
) ELSE TRUE END) AND
|
||||
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
||||
c.duration > @longer_than
|
||||
) ELSE TRUE END)
|
||||
;
|
||||
|
|
Loading…
Add table
Reference in a new issue