Complete incidents functionality #97
42 changed files with 1099 additions and 223 deletions
9
Makefile
9
Makefile
|
@ -14,8 +14,13 @@ buildpprof:
|
||||||
client/stillbox/dist:
|
client/stillbox/dist:
|
||||||
cd client/stillbox && npm install && ng build -c production
|
cd client/stillbox && npm install && ng build -c production
|
||||||
|
|
||||||
web:
|
web: web-install web-build
|
||||||
cd client/stillbox && npm install && ng build -c production
|
|
||||||
|
web-build:
|
||||||
|
cd client/stillbox && ng build -c production
|
||||||
|
|
||||||
|
web-install:
|
||||||
|
cd client/stillbox && npm install
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf client/calls/ client/stillbox/dist/ client/stillbox/node_modules/
|
rm -rf client/calls/ client/stillbox/dist/ client/stillbox/node_modules/
|
||||||
|
|
|
@ -61,6 +61,10 @@
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
"index": {
|
||||||
|
"input": "src/index.dev_nocsp.html",
|
||||||
|
"output": "index.html"
|
||||||
|
},
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
"replace": "src/environments/environment.ts",
|
"replace": "src/environments/environment.ts",
|
||||||
|
@ -73,6 +77,9 @@
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "src/proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "stillbox:build:production"
|
"buildTarget": "stillbox:build:production"
|
||||||
|
|
|
@ -43,14 +43,6 @@ export const routes: Routes = [
|
||||||
),
|
),
|
||||||
data: { title: 'Export Talkgroups' },
|
data: { title: 'Export Talkgroups' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'talkgroups/:sys/:tg',
|
|
||||||
loadComponent: () =>
|
|
||||||
import(
|
|
||||||
'./talkgroups/talkgroup-record/talkgroup-record.component'
|
|
||||||
).then((m) => m.TalkgroupRecordComponent),
|
|
||||||
data: { title: 'Edit Talkgroup' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'calls',
|
path: 'calls',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
@ -65,6 +57,14 @@ export const routes: Routes = [
|
||||||
),
|
),
|
||||||
data: { title: 'Incidents' },
|
data: { title: 'Incidents' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'incidents/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./incidents/incident/incident.component').then(
|
||||||
|
(m) => m.IncidentComponent,
|
||||||
|
),
|
||||||
|
data: { title: 'View Incident' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'alerts',
|
path: 'alerts',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|
|
@ -83,6 +83,20 @@
|
||||||
<button class="sbButton" (click)="refresh()">
|
<button class="sbButton" (click)="refresh()">
|
||||||
<mat-icon>refresh</mat-icon>
|
<mat-icon>refresh</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
[ngClass]="{ sbButton: true, hidden: !selection.hasValue() }"
|
||||||
|
[matMenuTriggerFor]="callMenu"
|
||||||
|
>
|
||||||
|
<mat-icon>playlist_add</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #callMenu="matMenu">
|
||||||
|
<button mat-menu-item (click)="addToNewInc($event)">
|
||||||
|
Add to new incident
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="addToExistingInc($event)">
|
||||||
|
Add to existing incident
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -109,9 +123,7 @@
|
||||||
<ng-container matColumnDef="play">
|
<ng-container matColumnDef="play">
|
||||||
<th mat-header-cell *matHeaderCellDef></th>
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
<td mat-cell *matCellDef="let call">
|
<td mat-cell *matCellDef="let call">
|
||||||
<a class="playButton" (click)="playAudio($event, call)"
|
<call-player [call]="call"></call-player>
|
||||||
><mat-icon>play_arrow</mat-icon></a
|
|
||||||
>
|
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="download">
|
<ng-container matColumnDef="download">
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Pipe,
|
||||||
|
PipeTransform,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
@ -29,6 +35,19 @@ import { MatInputModule } from '@angular/material/input';
|
||||||
import { debounceTime } from 'rxjs/operators';
|
import { debounceTime } from 'rxjs/operators';
|
||||||
import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { CallPlayerComponent } from './player/call-player/call-player.component';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import {
|
||||||
|
EditDialogData,
|
||||||
|
IncidentEditDialogComponent,
|
||||||
|
} from '../incidents/incident/incident.component';
|
||||||
|
import {
|
||||||
|
CallIncidentParams,
|
||||||
|
IncidentsService,
|
||||||
|
} from '../incidents/incidents.service';
|
||||||
|
import { IncidentRecord } from '../incidents';
|
||||||
|
import { SelectIncidentDialogComponent } from '../incidents/select-incident-dialog/select-incident-dialog.component';
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'grabDate',
|
name: 'grabDate',
|
||||||
|
@ -139,6 +158,8 @@ const reqPageSize = 200;
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
CallPlayerComponent,
|
||||||
|
MatMenuModule,
|
||||||
],
|
],
|
||||||
templateUrl: './calls.component.html',
|
templateUrl: './calls.component.html',
|
||||||
styleUrl: './calls.component.scss',
|
styleUrl: './calls.component.scss',
|
||||||
|
@ -147,6 +168,7 @@ export class CallsComponent {
|
||||||
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
|
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
|
||||||
@ViewChild('paginator') paginator!: MatPaginator;
|
@ViewChild('paginator') paginator!: MatPaginator;
|
||||||
count = 0;
|
count = 0;
|
||||||
|
dialog = inject(MatDialog);
|
||||||
page = 0;
|
page = 0;
|
||||||
perPage = 25;
|
perPage = 25;
|
||||||
pageSizeOptions = [25, 50, 75, 100, 200];
|
pageSizeOptions = [25, 50, 75, 100, 200];
|
||||||
|
@ -189,6 +211,7 @@ export class CallsComponent {
|
||||||
private prefsSvc: PrefsService,
|
private prefsSvc: PrefsService,
|
||||||
public tcSvc: ToolbarContextService,
|
public tcSvc: ToolbarContextService,
|
||||||
public tgSvc: TalkgroupService,
|
public tgSvc: TalkgroupService,
|
||||||
|
public incSvc: IncidentsService,
|
||||||
) {
|
) {
|
||||||
this.tcSvc.showFilterButton();
|
this.tcSvc.showFilterButton();
|
||||||
}
|
}
|
||||||
|
@ -243,13 +266,6 @@ export class CallsComponent {
|
||||||
return now.toISOString().slice(0, 16);
|
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) {
|
setPage(p: PageEvent, force?: boolean) {
|
||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
this.curPage = p;
|
this.curPage = p;
|
||||||
|
@ -343,4 +359,48 @@ export class CallsComponent {
|
||||||
this.form.controls['start'].setValue(this.lTime(new Date()));
|
this.form.controls['start'].setValue(this.lTime(new Date()));
|
||||||
this.form.controls['duration'].setValue(0);
|
this.form.controls['duration'].setValue(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addToNewInc(ev: Event) {
|
||||||
|
const dialogRef = this.dialog.open(IncidentEditDialogComponent, {
|
||||||
|
data: <EditDialogData>{
|
||||||
|
incID: '',
|
||||||
|
new: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe((res: IncidentRecord) => {
|
||||||
|
this.incSvc
|
||||||
|
.addRemoveCalls(res.id, <CallIncidentParams>{
|
||||||
|
add: this.selection.selected.map((s) => s.id),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.selection.clear();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
alert(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addToExistingInc(ev: Event) {
|
||||||
|
const dialogRef = this.dialog.open(SelectIncidentDialogComponent);
|
||||||
|
dialogRef.afterClosed().subscribe((res: string) => {
|
||||||
|
if (!res) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.incSvc
|
||||||
|
.addRemoveCalls(res, <CallIncidentParams>{
|
||||||
|
add: this.selection.selected.map((s) => s.id),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.selection.clear();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
alert(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
@if (playing) {
|
||||||
|
<a class="playButton" (click)="stopAudio($event)"
|
||||||
|
><mat-icon>stop</mat-icon></a
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
<a class="playButton" (click)="playAudio($event)"
|
||||||
|
><mat-icon>play_arrow</mat-icon></a
|
||||||
|
>
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { CallPlayerComponent } from './call-player.component';
|
||||||
|
|
||||||
|
describe('CallPlayerComponent', () => {
|
||||||
|
let component: CallPlayerComponent;
|
||||||
|
let fixture: ComponentFixture<CallPlayerComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CallPlayerComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CallPlayerComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CallsService } from '../../calls.service';
|
||||||
|
import { CallRecord } from '../../../calls';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { fromEvent, Observable, Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'call-player',
|
||||||
|
imports: [MatIconModule],
|
||||||
|
templateUrl: './call-player.component.html',
|
||||||
|
styleUrl: './call-player.component.scss',
|
||||||
|
})
|
||||||
|
export class CallPlayerComponent {
|
||||||
|
@Input() call!: CallRecord;
|
||||||
|
playing = false;
|
||||||
|
playSub!: Subscription;
|
||||||
|
au!: HTMLAudioElement;
|
||||||
|
|
||||||
|
constructor(private callsSvc: CallsService) {}
|
||||||
|
|
||||||
|
stopAudio(ev: Event) {
|
||||||
|
this.au.pause();
|
||||||
|
this.playing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAudio(ev: Event) {
|
||||||
|
this.au = new Audio();
|
||||||
|
this.playSub = fromEvent(this.au, 'ended').subscribe((ev) => {
|
||||||
|
this.playing = false;
|
||||||
|
this.playSub.unsubscribe();
|
||||||
|
});
|
||||||
|
this.playing = true;
|
||||||
|
this.au.src = this.callsSvc.callAudioURL(this.call.id);
|
||||||
|
this.au.load();
|
||||||
|
this.au.play().then(null, (reason) => {
|
||||||
|
this.playing = false;
|
||||||
|
alert(reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
<h2 mat-dialog-title>{{ title }}</h2>
|
||||||
|
<mat-dialog-content>
|
||||||
|
<div class="incRecord">
|
||||||
|
@let inc = inc$ | async;
|
||||||
|
@if (inc) {
|
||||||
|
<form id="incForm" [formGroup]="form" (ngSubmit)="save()">
|
||||||
|
<div>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input matInput name="name" type="text" formControlName="name" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
|
||||||
|
<mat-label>Start</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="datetime-local"
|
||||||
|
name="start"
|
||||||
|
placeholder="Start time"
|
||||||
|
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 time"
|
||||||
|
formControlName="end"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field subscriptSizing="dynamic" class="descBox">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
formControlName="description"
|
||||||
|
rows="8"
|
||||||
|
cols="200"
|
||||||
|
>{{ inc.description }}</textarea
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else {
|
||||||
|
<div class="spinner">
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions>
|
||||||
|
<button mat-button type="button" (click)="cancel()">Cancel</button>
|
||||||
|
<button mat-button form="incForm" type="submit" cdkFocusInitial>Save</button>
|
||||||
|
</mat-dialog-actions>
|
|
@ -1 +1,98 @@
|
||||||
<p>incident works!</p>
|
@let inc = inc$ | async;
|
||||||
|
<mat-card class="incident" appearance="outlined">
|
||||||
|
<div class="cardHdr">
|
||||||
|
<h1>{{ inc?.name }}</h1>
|
||||||
|
<button mat-icon-button (click)="editIncident(incID)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button [matMenuTriggerFor]="moreMenu">
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #moreMenu="matMenu">
|
||||||
|
<button class="deleteItem" mat-menu-item (click)="deleteIncident(incID)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
|
<div class="inc-heading">
|
||||||
|
<div class="field field-start field-label">Start</div>
|
||||||
|
<div class="field field-data field-start">
|
||||||
|
{{ inc?.startTime | fmtDate }}
|
||||||
|
</div>
|
||||||
|
<div class="field field-end field-label">End</div>
|
||||||
|
<div class="field field-data field-end">{{ inc?.endTime | fmtDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="inc-description">
|
||||||
|
{{ inc?.description }}
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<call-player [call]="call"></call-player>
|
||||||
|
</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>
|
||||||
|
</mat-card>
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
.incident {
|
||||||
|
margin: 50px 50px 50px 50px;
|
||||||
|
padding: 50px 50px 50px 50px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media not screen and (max-width: 768px) {
|
||||||
|
.incident {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-heading {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHdr {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
flex: 1 1;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
.field-label::after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHdr h1 {
|
||||||
|
flex: 1 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHdr a {
|
||||||
|
flex: 0 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
form mat-form-field {
|
||||||
|
width: 60rem;
|
||||||
|
flex: 0 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incRecord {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
|
@ -1,9 +1,231 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { CommonModule, Location } from '@angular/common';
|
||||||
|
import { BehaviorSubject, merge, Subscription } from 'rxjs';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormGroup,
|
||||||
|
FormControl,
|
||||||
|
FormsModule,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { IncidentsService } from '../incidents.service';
|
||||||
|
import { IncidentCall, IncidentRecord } from '../../incidents';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialog,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle,
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { SelectionModel } from '@angular/cdk/collections';
|
||||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
|
import {
|
||||||
|
FixedPointPipe,
|
||||||
|
TalkgroupPipe,
|
||||||
|
TimePipe,
|
||||||
|
DatePipe,
|
||||||
|
DownloadURLPipe,
|
||||||
|
} from '../../calls/calls.component';
|
||||||
|
import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component';
|
||||||
|
import { FmtDatePipe } from '../incidents.component';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
|
||||||
|
export interface EditDialogData {
|
||||||
|
incID: string;
|
||||||
|
new: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-incident-editor',
|
||||||
|
imports: [
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormsModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
CommonModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogContent,
|
||||||
|
MatButtonModule,
|
||||||
|
],
|
||||||
|
templateUrl: './incident-editor-dialog.component.html',
|
||||||
|
styleUrl: './incident.component.scss',
|
||||||
|
})
|
||||||
|
export class IncidentEditDialogComponent {
|
||||||
|
dialogRef = inject(MatDialogRef<IncidentEditDialogComponent>);
|
||||||
|
data = inject<EditDialogData>(MAT_DIALOG_DATA);
|
||||||
|
title = this.data.new ? 'New Incident' : 'Edit Incident';
|
||||||
|
inc$!: Observable<IncidentRecord>;
|
||||||
|
form = new FormGroup({
|
||||||
|
name: new FormControl(''),
|
||||||
|
start: new FormControl(),
|
||||||
|
end: new FormControl(),
|
||||||
|
description: new FormControl(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private incSvc: IncidentsService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (!this.data.new) {
|
||||||
|
this.inc$ = this.incSvc.getIncident(this.data.incID).pipe(
|
||||||
|
tap((inc) => {
|
||||||
|
this.form.patchValue(inc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.inc$ = new BehaviorSubject(<IncidentRecord>{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
let resObs: Observable<IncidentRecord>;
|
||||||
|
let ir: IncidentRecord = <IncidentRecord>{
|
||||||
|
name: this.form.controls['name'].dirty
|
||||||
|
? this.form.controls['name'].value
|
||||||
|
: null,
|
||||||
|
startTime: this.form.controls['start'].dirty
|
||||||
|
? this.form.controls['start'].value
|
||||||
|
: null,
|
||||||
|
endTime: this.form.controls['end'].dirty
|
||||||
|
? this.form.controls['end'].value
|
||||||
|
: null,
|
||||||
|
description: this.form.controls['description'].dirty
|
||||||
|
? this.form.controls['description'].value
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (this.data.new) {
|
||||||
|
resObs = this.incSvc.createIncident(ir);
|
||||||
|
} else {
|
||||||
|
resObs = this.incSvc.updateIncident(this.data.incID, ir);
|
||||||
|
}
|
||||||
|
resObs.subscribe({
|
||||||
|
next: (ok) => {
|
||||||
|
this.dialogRef.close(ok);
|
||||||
|
},
|
||||||
|
error: (er) => {
|
||||||
|
alert(er);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-incident',
|
selector: 'app-incident',
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormsModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule,
|
||||||
|
FixedPointPipe,
|
||||||
|
TimePipe,
|
||||||
|
DatePipe,
|
||||||
|
TalkgroupPipe,
|
||||||
|
DownloadURLPipe,
|
||||||
|
CallPlayerComponent,
|
||||||
|
FmtDatePipe,
|
||||||
|
MatTableModule,
|
||||||
|
MatMenuModule,
|
||||||
|
],
|
||||||
templateUrl: './incident.component.html',
|
templateUrl: './incident.component.html',
|
||||||
styleUrl: './incident.component.scss',
|
styleUrl: './incident.component.scss',
|
||||||
})
|
})
|
||||||
export class IncidentComponent {}
|
export class IncidentComponent {
|
||||||
|
incPrime = new BehaviorSubject<IncidentRecord>(<IncidentRecord>{});
|
||||||
|
inc$!: Observable<IncidentRecord>;
|
||||||
|
subscriptions: Subscription = new Subscription();
|
||||||
|
dialog = inject(MatDialog);
|
||||||
|
incID!: string;
|
||||||
|
columns = [
|
||||||
|
'select',
|
||||||
|
'play',
|
||||||
|
'download',
|
||||||
|
'date',
|
||||||
|
'time',
|
||||||
|
'system',
|
||||||
|
'group',
|
||||||
|
'talkgroup',
|
||||||
|
'duration',
|
||||||
|
];
|
||||||
|
callsResult = new MatTableDataSource<IncidentCall>();
|
||||||
|
selection = new SelectionModel<IncidentCall>(true, []);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private incSvc: IncidentsService,
|
||||||
|
private location: Location,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
saveIncName(ev: Event) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.incID = this.route.snapshot.paramMap.get('id')!;
|
||||||
|
this.inc$ = merge(this.incSvc.getIncident(this.incID), this.incPrime).pipe(
|
||||||
|
tap((inc) => {
|
||||||
|
if (inc.calls) {
|
||||||
|
this.callsResult.data = inc.calls;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
editIncident(incID: string) {
|
||||||
|
const dialogRef = this.dialog.open(IncidentEditDialogComponent, {
|
||||||
|
data: <EditDialogData>{
|
||||||
|
incID: incID,
|
||||||
|
new: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(this.incPrime);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIncident(incID: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this incident?')) {
|
||||||
|
this.incSvc.deleteIncident(incID).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.location.back();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
alert(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.subscriptions.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllSelected() {
|
||||||
|
const numSelected = this.selection.selected.length;
|
||||||
|
const numRows = this.callsResult.data.length;
|
||||||
|
return numSelected === numRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
masterToggle() {
|
||||||
|
this.isAllSelected()
|
||||||
|
? this.selection.clear()
|
||||||
|
: this.callsResult.data.forEach((row) => this.selection.select(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -34,12 +34,17 @@ import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
||||||
standalone: true,
|
standalone: true,
|
||||||
pure: true,
|
pure: true,
|
||||||
})
|
})
|
||||||
export class DatePipe implements PipeTransform {
|
export class FmtDatePipe implements PipeTransform {
|
||||||
transform(ts: string, args?: any): string {
|
transform(ts: string | Date | null | undefined, args?: any): string {
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
return '\u2014';
|
return '\u2014';
|
||||||
}
|
}
|
||||||
const timestamp = new Date(ts);
|
let timestamp: Date;
|
||||||
|
if (ts instanceof Date) {
|
||||||
|
timestamp = ts;
|
||||||
|
} else {
|
||||||
|
timestamp = new Date(ts);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
timestamp.getMonth() +
|
timestamp.getMonth() +
|
||||||
1 +
|
1 +
|
||||||
|
@ -61,7 +66,7 @@ const reqPageSize = 200;
|
||||||
selector: 'app-incidents',
|
selector: 'app-incidents',
|
||||||
imports: [
|
imports: [
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
DatePipe,
|
FmtDatePipe,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
|
|
@ -47,4 +47,8 @@ export class IncidentsService {
|
||||||
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
|
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
|
||||||
return this.http.patch<IncidentRecord>('/api/incident/' + id, inp);
|
return this.http.patch<IncidentRecord>('/api/incident/' + id, inp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getIncident(id: string): Observable<IncidentRecord> {
|
||||||
|
return this.http.get<IncidentRecord>('/api/incident/' + id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
<h2 mat-dialog-title>Select Incident</h2>
|
||||||
|
<mat-dialog-content>
|
||||||
|
<mat-form-field>
|
||||||
|
<input
|
||||||
|
#searchBox
|
||||||
|
matInput
|
||||||
|
name="search"
|
||||||
|
(input)="search(searchBox.value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="selList">
|
||||||
|
<mat-selection-list #incs [(ngModel)]="sel" multiple="false">
|
||||||
|
@let incidents = incs$ | async;
|
||||||
|
@for (inc of incidents; track inc) {
|
||||||
|
<mat-list-option [value]="inc.id">{{ inc.name }}</mat-list-option>
|
||||||
|
}
|
||||||
|
</mat-selection-list>
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions>
|
||||||
|
<button mat-button type="button" (click)="cancel()">Cancel</button>
|
||||||
|
<button mat-button (click)="save()" cdkFocusInitial>Select</button>
|
||||||
|
</mat-dialog-actions>
|
|
@ -0,0 +1,5 @@
|
||||||
|
.selList {
|
||||||
|
overflow: scroll;
|
||||||
|
width: 480px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SelectIncidentDialogComponent } from './select-incident-dialog.component';
|
||||||
|
|
||||||
|
describe('SelectIncidentDialogComponent', () => {
|
||||||
|
let component: SelectIncidentDialogComponent;
|
||||||
|
let fixture: ComponentFixture<SelectIncidentDialogComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SelectIncidentDialogComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SelectIncidentDialogComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import { Component, inject, ViewChild } from '@angular/core';
|
||||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatListModule, MatSelectionList } from '@angular/material/list';
|
||||||
|
import {
|
||||||
|
IncidentsListParams,
|
||||||
|
IncidentsPaginated,
|
||||||
|
IncidentsService,
|
||||||
|
} from '../incidents.service';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
Observable,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { IncidentRecord } from '../../incidents';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-select-incident-dialog',
|
||||||
|
imports: [
|
||||||
|
MatListModule,
|
||||||
|
MatInputModule,
|
||||||
|
AsyncPipe,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
FormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './select-incident-dialog.component.html',
|
||||||
|
styleUrl: './select-incident-dialog.component.scss',
|
||||||
|
})
|
||||||
|
export class SelectIncidentDialogComponent {
|
||||||
|
dialogRef = inject(MatDialogRef<SelectIncidentDialogComponent>);
|
||||||
|
allIncs$!: Observable<IncidentRecord[]>;
|
||||||
|
incs$!: Observable<IncidentRecord[]>;
|
||||||
|
findStr = new BehaviorSubject<string>('');
|
||||||
|
sel!: string;
|
||||||
|
constructor(private incSvc: IncidentsService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.incs$ = this.findStr.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((sub) =>
|
||||||
|
this.incSvc.getIncidents(<IncidentsListParams>{ filter: sub }),
|
||||||
|
),
|
||||||
|
map((incidents) => incidents.incidents),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(term: string) {
|
||||||
|
this.findStr.next(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.dialogRef.close(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.dialogRef.close(this.sel);
|
||||||
|
}
|
||||||
|
}
|
|
@ -89,6 +89,13 @@ export class Talkgroup {
|
||||||
iconMap(icon: string): string {
|
iconMap(icon: string): string {
|
||||||
return iconMapping[icon]!;
|
return iconMapping[icon]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tgTuple(): TGID {
|
||||||
|
return <TGID>{
|
||||||
|
sys: this.system_id,
|
||||||
|
tg: this.tgid,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TalkgroupUI extends Talkgroup {
|
export interface TalkgroupUI extends Talkgroup {
|
||||||
|
|
|
@ -1,108 +1,124 @@
|
||||||
<div class="tgRecord">
|
<h2 mat-dialog-title>Edit {{ tgid.sys }}:{{ tgid.tg }}</h2>
|
||||||
<form *ngIf="tg" [formGroup]="form" (ngSubmit)="submit()">
|
<mat-dialog-content>
|
||||||
<div>
|
<div class="tgRecord">
|
||||||
<mat-form-field>
|
@let tg = tg$ | async;
|
||||||
<mat-label>Name</mat-label>
|
@if (tg) {
|
||||||
<input matInput name="name" type="text" formControlName="name" />
|
<form id="tgForm" [formGroup]="form" (ngSubmit)="save()">
|
||||||
</mat-form-field>
|
<div>
|
||||||
</div>
|
<mat-form-field>
|
||||||
<div>
|
<mat-label>Name</mat-label>
|
||||||
<mat-form-field>
|
<input matInput name="name" type="text" formControlName="name" />
|
||||||
<mat-label>Alpha Tag</mat-label
|
</mat-form-field>
|
||||||
><input
|
</div>
|
||||||
matInput
|
<div>
|
||||||
name="alpha_tag"
|
<mat-form-field>
|
||||||
type="text"
|
<mat-label>Alpha Tag</mat-label
|
||||||
formControlName="alpha_tag"
|
><input
|
||||||
/>
|
matInput
|
||||||
</mat-form-field>
|
name="alpha_tag"
|
||||||
</div>
|
type="text"
|
||||||
<div>
|
formControlName="alpha_tag"
|
||||||
<mat-form-field>
|
/>
|
||||||
<mat-label>Group</mat-label
|
</mat-form-field>
|
||||||
><input
|
</div>
|
||||||
matInput
|
<div>
|
||||||
name="tg_group"
|
<mat-form-field>
|
||||||
type="text"
|
<mat-label>Group</mat-label
|
||||||
formControlName="tg_group"
|
><input
|
||||||
/>
|
matInput
|
||||||
</mat-form-field>
|
name="tg_group"
|
||||||
</div>
|
type="text"
|
||||||
<div>
|
formControlName="tg_group"
|
||||||
<mat-form-field>
|
/>
|
||||||
<mat-label>Frequency</mat-label
|
</mat-form-field>
|
||||||
><input
|
</div>
|
||||||
matInput
|
<div class="twoRow">
|
||||||
name="frequency"
|
<mat-form-field>
|
||||||
type="text"
|
<mat-label>Frequency</mat-label
|
||||||
formControlName="frequency"
|
><input
|
||||||
/>
|
matInput
|
||||||
</mat-form-field>
|
name="frequency"
|
||||||
</div>
|
type="text"
|
||||||
<div>
|
formControlName="frequency"
|
||||||
<mat-form-field>
|
/>
|
||||||
<mat-label>Weight</mat-label
|
</mat-form-field>
|
||||||
><input matInput name="weight" type="text" formControlName="weight" />
|
<mat-form-field>
|
||||||
</mat-form-field>
|
<mat-label>Weight</mat-label
|
||||||
</div>
|
><input
|
||||||
<div>
|
matInput
|
||||||
<mat-form-field>
|
name="weight"
|
||||||
<mat-label>Icon</mat-label>
|
type="text"
|
||||||
<select matNativeControl name="icon" formControlName="icon">
|
formControlName="weight"
|
||||||
@for (opt of iconMapping | keyvalue; track opt) {
|
/>
|
||||||
<option
|
</mat-form-field>
|
||||||
value="{{ opt.key }}"
|
</div>
|
||||||
[selected]="opt.key == tg.metadata?.icon"
|
<div>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Icon</mat-label>
|
||||||
|
<select matNativeControl name="icon" formControlName="icon">
|
||||||
|
@for (opt of iconMapping | keyvalue; track opt) {
|
||||||
|
<option
|
||||||
|
value="{{ opt.key }}"
|
||||||
|
[selected]="opt.key == tg.metadata?.icon"
|
||||||
|
>
|
||||||
|
{{ opt.key | titlecase }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field class="tagsField">
|
||||||
|
<mat-label>Tags</mat-label>
|
||||||
|
<mat-chip-grid #tagsChipGrid formControlName="tagsControl">
|
||||||
|
@for (tag of tg.tags; track tag) {
|
||||||
|
<mat-chip-row (removed)="removeTag(tag)">
|
||||||
|
{{ tag }}
|
||||||
|
<button matChipRemove>
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-chip-row>
|
||||||
|
}
|
||||||
|
</mat-chip-grid>
|
||||||
|
<input
|
||||||
|
name="tag"
|
||||||
|
placeholder="New tag..."
|
||||||
|
[matChipInputFor]="tagsChipGrid"
|
||||||
|
(matChipInputTokenEnd)="addTagEv($event)"
|
||||||
|
[matChipInputAddOnBlur]="false"
|
||||||
|
[formControlName]="'tagInput'"
|
||||||
|
#tagInput
|
||||||
|
[matAutocomplete]="auto"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
/>
|
||||||
|
<mat-autocomplete
|
||||||
|
#auto="matAutocomplete"
|
||||||
|
(optionSelected)="selected($event)"
|
||||||
|
(optionActivated)="activated($event)"
|
||||||
>
|
>
|
||||||
{{ opt.key | titlecase }}
|
@for (tag of filteredTags(); track tag) {
|
||||||
</option>
|
<mat-option [value]="tag">{{ tag }}</mat-option>
|
||||||
}
|
}
|
||||||
</select>
|
</mat-autocomplete>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="alert">
|
||||||
<mat-form-field class="tagsField">
|
<mat-checkbox name="alert" formControlName="alert"
|
||||||
<mat-label>Tags</mat-label>
|
>Alert</mat-checkbox
|
||||||
<mat-chip-grid #tagsChipGrid formControlName="tagsControl">
|
>
|
||||||
@for (tag of tg.tags; track tag) {
|
</div>
|
||||||
<mat-chip-row (removed)="removeTag(tag)">
|
<div class="alert">
|
||||||
{{ tag }}
|
<alert-rule-builder [rules]="tg.alert_config" />
|
||||||
<button matChipRemove>
|
</div>
|
||||||
<mat-icon>cancel</mat-icon>
|
</form>
|
||||||
</button>
|
} @else {
|
||||||
</mat-chip-row>
|
<div class="spinner">
|
||||||
}
|
<mat-spinner></mat-spinner>
|
||||||
</mat-chip-grid>
|
</div>
|
||||||
<input
|
}
|
||||||
name="tag"
|
</div>
|
||||||
placeholder="New tag..."
|
</mat-dialog-content>
|
||||||
[matChipInputFor]="tagsChipGrid"
|
<mat-dialog-actions>
|
||||||
(matChipInputTokenEnd)="addTagEv($event)"
|
<button mat-button type="button" (click)="cancel()">Cancel</button>
|
||||||
[matChipInputAddOnBlur]="false"
|
<button mat-button form="tgForm" type="submit" cdkFocusInitial>Save</button>
|
||||||
[formControlName]="'tagInput'"
|
</mat-dialog-actions>
|
||||||
#tagInput
|
|
||||||
[matAutocomplete]="auto"
|
|
||||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
|
||||||
/>
|
|
||||||
<mat-autocomplete
|
|
||||||
#auto="matAutocomplete"
|
|
||||||
(optionSelected)="selected($event)"
|
|
||||||
(optionActivated)="activated($event)"
|
|
||||||
>
|
|
||||||
@for (tag of filteredTags(); track tag) {
|
|
||||||
<mat-option [value]="tag">{{ tag }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-autocomplete>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<mat-label>Alert</mat-label>
|
|
||||||
<mat-checkbox name="alert" formControlName="alert" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Rules:
|
|
||||||
<alert-rule-builder [rules]="tg.alert_config" />
|
|
||||||
</div>
|
|
||||||
<button class="sbButton" type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -1,9 +1,24 @@
|
||||||
mat-form-field {
|
form div {
|
||||||
width: 30rem;
|
width: 30rem;
|
||||||
|
flex: 0 0 30rem;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field,
|
||||||
|
.alert,
|
||||||
|
alert-rule-builder {
|
||||||
|
width: 30rem;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tgRecord {
|
.tgRecord {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
width: 31rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { Component, computed, inject, ViewChild } from '@angular/core';
|
import { Component, computed, inject, ViewChild } from '@angular/core';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { debounceTime } from 'rxjs/operators';
|
import { debounceTime, tap } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
Talkgroup,
|
Talkgroup,
|
||||||
TalkgroupUpdate,
|
TalkgroupUpdate,
|
||||||
IconMap,
|
IconMap,
|
||||||
iconMapping,
|
iconMapping,
|
||||||
|
TGID,
|
||||||
} from '../../talkgroup';
|
} from '../../talkgroup';
|
||||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||||
import { TalkgroupService } from '../talkgroups.service';
|
import { TalkgroupService } from '../talkgroups.service';
|
||||||
|
@ -17,7 +18,7 @@ import {
|
||||||
MatAutocompleteActivatedEvent,
|
MatAutocompleteActivatedEvent,
|
||||||
} from '@angular/material/autocomplete';
|
} from '@angular/material/autocomplete';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { BehaviorSubject, catchError, of, Subscription } from 'rxjs';
|
import { catchError, of, Subscription } from 'rxjs';
|
||||||
import { shareReplay } from 'rxjs/operators';
|
import { shareReplay } from 'rxjs/operators';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
@ -26,12 +27,21 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
|
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle,
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'talkgroup-record',
|
selector: 'talkgroup-record',
|
||||||
|
@ -46,11 +56,19 @@ import { MatIconModule } from '@angular/material/icon';
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatAutocompleteModule,
|
MatAutocompleteModule,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatButtonModule,
|
||||||
],
|
],
|
||||||
templateUrl: './talkgroup-record.component.html',
|
templateUrl: './talkgroup-record.component.html',
|
||||||
styleUrl: './talkgroup-record.component.scss',
|
styleUrl: './talkgroup-record.component.scss',
|
||||||
})
|
})
|
||||||
export class TalkgroupRecordComponent {
|
export class TalkgroupRecordComponent {
|
||||||
|
dialogRef = inject(MatDialogRef<TalkgroupRecordComponent>);
|
||||||
|
tgid = inject<TGID>(MAT_DIALOG_DATA);
|
||||||
|
tg$!: Observable<Talkgroup>;
|
||||||
tg!: Talkgroup;
|
tg!: Talkgroup;
|
||||||
iconMapping: IconMap = iconMapping;
|
iconMapping: IconMap = iconMapping;
|
||||||
tgService: TalkgroupService = inject(TalkgroupService);
|
tgService: TalkgroupService = inject(TalkgroupService);
|
||||||
|
@ -82,10 +100,7 @@ export class TalkgroupRecordComponent {
|
||||||
active: string | null = null;
|
active: string | null = null;
|
||||||
subscriptions = new Subscription();
|
subscriptions = new Subscription();
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router,
|
|
||||||
) {
|
|
||||||
this._allTags = this.tgService.allTags().pipe(shareReplay());
|
this._allTags = this.tgService.allTags().pipe(shareReplay());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,25 +153,16 @@ export class TalkgroupRecordComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const sysId = this.route.snapshot.paramMap.get('sys');
|
this.tg$ = this.tgService
|
||||||
const tgId = this.route.snapshot.paramMap.get('tg');
|
.getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg))
|
||||||
|
.pipe(
|
||||||
this.subscriptions.add(
|
tap((tg) => {
|
||||||
this.tgService
|
this.form.patchValue(tg);
|
||||||
.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['tagInput'].setValue('');
|
||||||
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
|
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.subscriptions.add(
|
this.subscriptions.add(
|
||||||
this._allTags.subscribe((event) => {
|
this._allTags.subscribe((event) => {
|
||||||
this.allTags = event;
|
this.allTags = event;
|
||||||
|
@ -168,11 +174,10 @@ export class TalkgroupRecordComponent {
|
||||||
this.subscriptions.unsubscribe();
|
this.subscriptions.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
submit() {
|
save() {
|
||||||
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
|
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
|
||||||
system_id: this.tg.system_id,
|
system_id: this.tgid.sys,
|
||||||
tgid: this.tg.tgid,
|
tgid: this.tgid.tg,
|
||||||
id: this.tg.id,
|
|
||||||
};
|
};
|
||||||
if (this.form.controls['name'].dirty) {
|
if (this.form.controls['name'].dirty) {
|
||||||
tgu.name = this.form.controls['name'].value;
|
tgu.name = this.form.controls['name'].value;
|
||||||
|
@ -208,15 +213,21 @@ export class TalkgroupRecordComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.tgService
|
this.subscriptions.add(
|
||||||
.putTalkgroup(tgu)
|
this.tgService
|
||||||
.pipe(
|
.putTalkgroup(tgu)
|
||||||
catchError(() => {
|
.pipe(
|
||||||
return of(null);
|
catchError(() => {
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe((newTG) => {
|
||||||
|
this.dialogRef.close(newTG);
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
.subscribe((event) => {
|
}
|
||||||
this.router.navigate(['/talkgroups/']);
|
|
||||||
});
|
cancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,8 +64,8 @@
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="edit">
|
<ng-container matColumnDef="edit">
|
||||||
<th mat-header-cell *matHeaderCellDef>Edit</th>
|
<th mat-header-cell *matHeaderCellDef>Edit</th>
|
||||||
<td mat-cell *matCellDef="let tg">
|
<td mat-cell *matCellDef="let tg; let i = index">
|
||||||
<a routerLink="/talkgroups/{{ tg.system?.id }}/{{ tg.tgid }}"
|
<a (click)="editTG(i, tg.system?.id, tg.tgid)"
|
||||||
><mat-icon>edit</mat-icon>
|
><mat-icon>edit</mat-icon>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { TalkgroupService, TalkgroupsPaginated } from '../talkgroups.service';
|
import { TalkgroupService, TalkgroupsPaginated } from '../talkgroups.service';
|
||||||
import { Talkgroup, iconMapping } from '../../talkgroup';
|
import { TGID, Talkgroup, iconMapping } from '../../talkgroup';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { RouterModule, RouterLink } from '@angular/router';
|
import { RouterModule, RouterLink } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
@ -27,6 +27,16 @@ import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { SelectionModel } from '@angular/cdk/collections';
|
import { SelectionModel } from '@angular/cdk/collections';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialog,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle,
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { TalkgroupRecordComponent } from '../talkgroup-record/talkgroup-record.component';
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
@ -59,7 +69,6 @@ export class SanitizeHtmlPipe implements PipeTransform {
|
||||||
selector: 'talkgroup-table',
|
selector: 'talkgroup-table',
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule,
|
RouterModule,
|
||||||
RouterLink,
|
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
IconifyPipe,
|
IconifyPipe,
|
||||||
|
@ -104,6 +113,7 @@ export class TalkgroupTableComponent {
|
||||||
@Input() resetPage!: Observable<void>;
|
@Input() resetPage!: Observable<void>;
|
||||||
@ViewChild('paginator') paginator!: MatPaginator;
|
@ViewChild('paginator') paginator!: MatPaginator;
|
||||||
suppress = false;
|
suppress = false;
|
||||||
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
constructor(private route: ActivatedRoute) {}
|
constructor(private route: ActivatedRoute) {}
|
||||||
|
|
||||||
|
@ -156,4 +166,18 @@ export class TalkgroupTableComponent {
|
||||||
? this.selection.clear()
|
? this.selection.clear()
|
||||||
: this.dataSource.data.forEach((row) => this.selection.select(row));
|
: this.dataSource.data.forEach((row) => this.selection.select(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editTG(idx: number, sys: number, tg: number) {
|
||||||
|
const tgid = <TGID>{ sys: sys, tg: tg };
|
||||||
|
const dialogRef = this.dialog.open(TalkgroupRecordComponent, {
|
||||||
|
data: tgid,
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((res) => {
|
||||||
|
if (res !== undefined) {
|
||||||
|
this.dataSource.data[idx] = res;
|
||||||
|
this.dataSource.data = this.dataSource.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ export class TalkgroupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getTalkgroups(): Observable<Talkgroup[]> {
|
getTalkgroups(): Observable<Talkgroup[]> {
|
||||||
return this.http.get<Talkgroup[]>('/api/talkgroup/');
|
return this.http.get<Talkgroup[]>('/api/talkgroup/').pipe(shareReplay());
|
||||||
}
|
}
|
||||||
|
|
||||||
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
|
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
|
||||||
|
@ -64,7 +64,7 @@ export class TalkgroupService {
|
||||||
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
|
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
|
||||||
let tgid = this.tgKey(tu.system_id, tu.tgid);
|
let tgid = this.tgKey(tu.system_id, tu.tgid);
|
||||||
|
|
||||||
this.http
|
return this.http
|
||||||
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
|
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((tg) => {
|
switchMap((tg) => {
|
||||||
|
@ -78,8 +78,6 @@ export class TalkgroupService {
|
||||||
return tObs;
|
return tObs;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this._getTalkgroup.get(tgid)!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
putTalkgroups(
|
putTalkgroups(
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
export const environment = {
|
export const environment = {
|
||||||
baseUrl: 'http://xenon:3050',
|
baseUrl: '',
|
||||||
};
|
};
|
||||||
|
|
19
client/stillbox/src/index.dev_nocsp.html
Normal file
19
client/stillbox/src/index.dev_nocsp.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<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" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="media-src *" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
<noscript
|
||||||
|
>Please enable JavaScript to continue using this application.</noscript
|
||||||
|
>
|
||||||
|
</body>
|
||||||
|
</html>
|
6
client/stillbox/src/proxy.conf.json
Normal file
6
client/stillbox/src/proxy.conf.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://xenon:3050",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@
|
||||||
// Include the common styles for Angular Material. We include this here so that you only
|
// 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.
|
// have to load a single css file for Angular Material in your app.
|
||||||
// Be sure that you only ever include this mixin once!
|
// Be sure that you only ever include this mixin once!
|
||||||
|
@include mat.core();
|
||||||
@include mat.elevation-classes();
|
@include mat.elevation-classes();
|
||||||
@include mat.app-background();
|
@include mat.app-background();
|
||||||
|
|
||||||
|
@ -222,7 +223,8 @@ body {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
textarea {
|
||||||
caret-color: var(--color-dark-fg) !important;
|
caret-color: var(--color-dark-fg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,3 +238,8 @@ input {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-mdc-menu-item.deleteItem {
|
||||||
|
color: white;
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,9 @@ auth:
|
||||||
# this allows the JWT cookie to be served over plain HTTP only for these Host: header values
|
# this allows the JWT cookie to be served over plain HTTP only for these Host: header values
|
||||||
allowInsecureFor:
|
allowInsecureFor:
|
||||||
"localhost": true
|
"localhost": true
|
||||||
|
# this instead changes the meaning of allowInsecureFor to the cookie being marked
|
||||||
|
# Secure, but SameSite will be set to None
|
||||||
|
sameSiteNoneForInsecure: false
|
||||||
listen: ':3050'
|
listen: ':3050'
|
||||||
public: true
|
public: true
|
||||||
log:
|
log:
|
||||||
|
|
|
@ -32,7 +32,7 @@ func call(url string, call *calls.Call) error {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
body := multipart.NewWriter(&buf)
|
body := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
err := forms.Marshal(call, body, forms.WithTag("json"))
|
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("relay form parse: %w", err)
|
return fmt.Errorf("relay form parse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -145,6 +145,16 @@ func (a *Auth) allowInsecureCookie(r *http.Request) bool {
|
||||||
return has && v
|
return has && v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Auth) setInsecureCookie(cookie *http.Cookie) {
|
||||||
|
if a.cfg.SameSiteNoneWhenInsecure {
|
||||||
|
cookie.Secure = true
|
||||||
|
cookie.SameSite = http.SameSiteNoneMode
|
||||||
|
} else {
|
||||||
|
cookie.Secure = false
|
||||||
|
cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
jwToken, _, err := jwtauth.FromContext(r.Context())
|
jwToken, _, err := jwtauth.FromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -174,8 +184,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.allowInsecureCookie(r) {
|
if a.allowInsecureCookie(r) {
|
||||||
cookie.Secure = false
|
a.setInsecureCookie(cookie)
|
||||||
cookie.SameSite = http.SameSiteLaxMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookie.Secure {
|
if cookie.Secure {
|
||||||
|
@ -236,8 +245,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
cookie.Domain = r.Host
|
cookie.Domain = r.Host
|
||||||
if a.allowInsecureCookie(r) {
|
if a.allowInsecureCookie(r) {
|
||||||
cookie.Secure = false
|
a.setInsecureCookie(cookie)
|
||||||
cookie.SameSite = http.SameSiteLaxMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
@ -263,8 +271,8 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
cookie.Domain = r.Host
|
cookie.Domain = r.Host
|
||||||
if a.allowInsecureCookie(r) {
|
if a.allowInsecureCookie(r) {
|
||||||
cookie.Secure = false
|
cookie.Secure = true
|
||||||
cookie.SameSite = http.SameSiteLaxMode
|
cookie.SameSite = http.SameSiteNoneMode
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package calls
|
package calls
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -29,6 +30,10 @@ func (d CallDuration) MsInt32Ptr() *int32 {
|
||||||
return &i
|
return &i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d CallDuration) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(d.Duration().Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
func (d CallDuration) Seconds() int32 {
|
func (d CallDuration) Seconds() int32 {
|
||||||
return int32(time.Duration(d).Seconds())
|
return int32(time.Duration(d).Seconds())
|
||||||
}
|
}
|
||||||
|
@ -41,24 +46,29 @@ type CallAudio struct {
|
||||||
AudioBlob []byte `json:"audioBlob"`
|
AudioBlob []byte `json:"audioBlob"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tags here are snake_case for compatibility with sqlc generated
|
||||||
|
// struct tags in ListCallsPRow. This allows the heavier-weight calls
|
||||||
|
// queries/endpoints to render DB output directly to the wire without
|
||||||
|
// further transformation. relayOut exists for compatibility with http
|
||||||
|
// source CallUploadRequest as used in the relay sink.
|
||||||
type Call struct {
|
type Call struct {
|
||||||
ID uuid.UUID `json:"-"`
|
ID uuid.UUID `json:"id" relayOut:"id"`
|
||||||
Audio []byte `json:"audio,omitempty" filenameField:"AudioName"`
|
Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"`
|
||||||
AudioName string `json:"audioName,omitempty"`
|
AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"`
|
||||||
AudioType string `json:"audioType,omitempty"`
|
AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"`
|
||||||
Duration CallDuration `json:"-"`
|
Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"`
|
||||||
DateTime time.Time `json:"dateTime,omitempty"`
|
DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"`
|
||||||
Frequencies []int `json:"frequencies,omitempty"`
|
Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"`
|
||||||
Frequency int `json:"frequency,omitempty"`
|
Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"`
|
||||||
Patches []int `json:"patches,omitempty"`
|
Patches []int `json:"patches,omitempty" relayOut:"patches,omitempty"`
|
||||||
Source int `json:"source,omitempty"`
|
Source int `json:"source,omitempty" relayOut:"source,omitempty"`
|
||||||
System int `json:"system,omitempty"`
|
System int `json:"system_id,omitempty" relayOut:"system,omitempty"`
|
||||||
Submitter *auth.UserID `json:"-,omitempty"`
|
Submitter *auth.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"`
|
||||||
SystemLabel string `json:"systemLabel,omitempty"`
|
SystemLabel string `json:"system_name,omitempty" relayOut:"systemLabel,omitempty"`
|
||||||
Talkgroup int `json:"talkgroup,omitempty"`
|
Talkgroup int `json:"tgid,omitempty" relayOut:"talkgroup,omitempty"`
|
||||||
TalkgroupGroup *string `json:"talkgroupGroup,omitempty"`
|
TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,omitempty"`
|
||||||
TalkgroupLabel *string `json:"talkgroupLabel,omitempty"`
|
TalkgroupLabel *string `json:"talkgroupLabel,omitempty" relayOut:"talkgroupLabel,omitempty"`
|
||||||
TGAlphaTag *string `json:"talkgroupTag,omitempty"`
|
TGAlphaTag *string `json:"tg_name,omitempty" relayOut:"talkgroupTag,omitempty"`
|
||||||
|
|
||||||
shouldStore bool `json:"-"`
|
shouldStore bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,8 +30,9 @@ type Config struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
JWTSecret string `yaml:"jwtsecret"`
|
JWTSecret string `yaml:"jwtsecret"`
|
||||||
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
|
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
|
||||||
|
SameSiteNoneWhenInsecure bool `yaml:"sameSiteNoneForInsecure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CORS struct {
|
type CORS struct {
|
||||||
|
|
|
@ -147,7 +147,6 @@ WITH to_sweep AS (
|
||||||
WHERE call_id IN (SELECT id FROM to_sweep)
|
WHERE call_id IN (SELECT id FROM to_sweep)
|
||||||
`
|
`
|
||||||
|
|
||||||
// This is used to sweep calls that are part of an incident prior to pruning a partition.
|
|
||||||
func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
|
func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
|
||||||
result, err := q.db.Exec(ctx, cleanupSweptCalls, rangeStart, rangeEnd)
|
result, err := q.db.Exec(ctx, cleanupSweptCalls, rangeStart, rangeEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -366,6 +365,7 @@ WITH to_sweep AS (
|
||||||
) INSERT INTO swept_calls SELECT id, submitter, system, talkgroup, call_date, audio_name, audio_blob, duration, audio_type, audio_url, frequency, frequencies, patches, tg_label, tg_alpha_tag, tg_group, source, transcript FROM to_sweep
|
) INSERT INTO swept_calls SELECT id, submitter, system, talkgroup, call_date, audio_name, audio_blob, duration, audio_type, audio_url, frequency, frequencies, patches, tg_label, tg_alpha_tag, tg_group, source, transcript FROM to_sweep
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// This is used to sweep calls that are part of an incident prior to pruning a partition.
|
||||||
func (q *Queries) SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
|
func (q *Queries) SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
|
||||||
result, err := q.db.Exec(ctx, sweepCalls, rangeStart, rangeEnd)
|
result, err := q.db.Exec(ctx, sweepCalls, rangeStart, rangeEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -132,7 +132,22 @@ func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, erro
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIncidentCalls = `-- name: GetIncidentCalls :many
|
const getIncidentCalls = `-- name: GetIncidentCalls :many
|
||||||
SELECT ic.call_id, ic.call_date, ic.notes, c.submitter, c.system, c.talkgroup, c.audio_name, c.duration, c.audio_type, c.audio_url, c.frequency, c.frequencies, c.patches, c.source, c.transcript
|
SELECT
|
||||||
|
ic.call_id,
|
||||||
|
ic.call_date,
|
||||||
|
c.duration,
|
||||||
|
c.system system_id,
|
||||||
|
c.talkgroup tgid,
|
||||||
|
ic.notes,
|
||||||
|
c.submitter,
|
||||||
|
c.audio_name,
|
||||||
|
c.audio_type,
|
||||||
|
c.audio_url,
|
||||||
|
c.frequency,
|
||||||
|
c.frequencies,
|
||||||
|
c.patches,
|
||||||
|
c.source,
|
||||||
|
c.transcript
|
||||||
FROM incidents_calls ic, LATERAL (
|
FROM incidents_calls ic, LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
ca.submitter,
|
ca.submitter,
|
||||||
|
@ -170,12 +185,12 @@ WHERE ic.incident_id = $1
|
||||||
type GetIncidentCallsRow struct {
|
type GetIncidentCallsRow struct {
|
||||||
CallID uuid.UUID `json:"call_id"`
|
CallID uuid.UUID `json:"call_id"`
|
||||||
CallDate pgtype.Timestamptz `json:"call_date"`
|
CallDate pgtype.Timestamptz `json:"call_date"`
|
||||||
|
Duration *int32 `json:"duration"`
|
||||||
|
SystemID int `json:"system_id"`
|
||||||
|
TGID int `json:"tgid"`
|
||||||
Notes []byte `json:"notes"`
|
Notes []byte `json:"notes"`
|
||||||
Submitter *int32 `json:"submitter"`
|
Submitter *int32 `json:"submitter"`
|
||||||
System int `json:"system"`
|
|
||||||
Talkgroup int `json:"talkgroup"`
|
|
||||||
AudioName *string `json:"audio_name"`
|
AudioName *string `json:"audio_name"`
|
||||||
Duration *int32 `json:"duration"`
|
|
||||||
AudioType *string `json:"audio_type"`
|
AudioType *string `json:"audio_type"`
|
||||||
AudioUrl *string `json:"audio_url"`
|
AudioUrl *string `json:"audio_url"`
|
||||||
Frequency int `json:"frequency"`
|
Frequency int `json:"frequency"`
|
||||||
|
@ -197,12 +212,12 @@ func (q *Queries) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetInci
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.CallID,
|
&i.CallID,
|
||||||
&i.CallDate,
|
&i.CallDate,
|
||||||
|
&i.Duration,
|
||||||
|
&i.SystemID,
|
||||||
|
&i.TGID,
|
||||||
&i.Notes,
|
&i.Notes,
|
||||||
&i.Submitter,
|
&i.Submitter,
|
||||||
&i.System,
|
|
||||||
&i.Talkgroup,
|
|
||||||
&i.AudioName,
|
&i.AudioName,
|
||||||
&i.Duration,
|
|
||||||
&i.AudioType,
|
&i.AudioType,
|
||||||
&i.AudioUrl,
|
&i.AudioUrl,
|
||||||
&i.Frequency,
|
&i.Frequency,
|
||||||
|
|
|
@ -16,7 +16,6 @@ type Querier interface {
|
||||||
AddCall(ctx context.Context, arg AddCallParams) error
|
AddCall(ctx context.Context, arg AddCallParams) error
|
||||||
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error)
|
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error)
|
||||||
AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error
|
AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error
|
||||||
// This is used to sweep calls that are part of an incident prior to pruning a partition.
|
|
||||||
CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error)
|
CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error)
|
||||||
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
|
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
|
||||||
CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error)
|
CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error)
|
||||||
|
@ -62,6 +61,7 @@ type Querier interface {
|
||||||
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
|
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
|
||||||
StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error
|
StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error
|
||||||
StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults
|
StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults
|
||||||
|
// This is used to sweep calls that are part of an incident prior to pruning a partition.
|
||||||
SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error)
|
SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error)
|
||||||
UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error
|
UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error
|
||||||
UpdateIncident(ctx context.Context, arg UpdateIncidentParams) (Incident, error)
|
UpdateIncident(ctx context.Context, arg UpdateIncidentParams) (Incident, error)
|
||||||
|
|
|
@ -240,9 +240,9 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
|
||||||
Frequency: v.Frequency,
|
Frequency: v.Frequency,
|
||||||
Patches: v.Patches,
|
Patches: v.Patches,
|
||||||
Source: v.Source,
|
Source: v.Source,
|
||||||
System: v.System,
|
System: v.SystemID,
|
||||||
Submitter: sub,
|
Submitter: sub,
|
||||||
Talkgroup: v.Talkgroup,
|
Talkgroup: v.TGID,
|
||||||
},
|
},
|
||||||
Notes: v.Notes,
|
Notes: v.Notes,
|
||||||
})
|
})
|
||||||
|
|
|
@ -56,7 +56,7 @@ func (ia *incidentsAPI) listIncidents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
res := struct {
|
res := struct {
|
||||||
Incidents []incstore.Incident `json:"incidents"`
|
Incidents []incstore.Incident `json:"incidents"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
res.Incidents, res.Count, err = incs.Incidents(ctx, p)
|
res.Incidents, res.Count, err = incs.Incidents(ctx, p)
|
||||||
|
|
|
@ -81,7 +81,7 @@ func (s *Relay) Call(ctx context.Context, call *calls.Call) error {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
body := multipart.NewWriter(&buf)
|
body := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
err := forms.Marshal(call, body, forms.WithTag("json"))
|
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("relay form parse: %w", err)
|
return fmt.Errorf("relay form parse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,7 +94,22 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
;
|
;
|
||||||
|
|
||||||
-- name: GetIncidentCalls :many
|
-- name: GetIncidentCalls :many
|
||||||
SELECT ic.call_id, ic.call_date, ic.notes, c.*
|
SELECT
|
||||||
|
ic.call_id,
|
||||||
|
ic.call_date,
|
||||||
|
c.duration,
|
||||||
|
c.system system_id,
|
||||||
|
c.talkgroup tgid,
|
||||||
|
ic.notes,
|
||||||
|
c.submitter,
|
||||||
|
c.audio_name,
|
||||||
|
c.audio_type,
|
||||||
|
c.audio_url,
|
||||||
|
c.frequency,
|
||||||
|
c.frequencies,
|
||||||
|
c.patches,
|
||||||
|
c.source,
|
||||||
|
c.transcript
|
||||||
FROM incidents_calls ic, LATERAL (
|
FROM incidents_calls ic, LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
ca.submitter,
|
ca.submitter,
|
||||||
|
|
Loading…
Add table
Reference in a new issue