Merge pull request 'Complete incidents functionality' (#97) from incidentsEditRecord into trunk
Reviewed-on: #97
This commit is contained in:
commit
a5fc6825b1
42 changed files with 1099 additions and 223 deletions
9
Makefile
9
Makefile
|
@ -14,8 +14,13 @@ buildpprof:
|
|||
client/stillbox/dist:
|
||||
cd client/stillbox && npm install && ng build -c production
|
||||
|
||||
web:
|
||||
cd client/stillbox && npm install && ng build -c production
|
||||
web: web-install web-build
|
||||
|
||||
web-build:
|
||||
cd client/stillbox && ng build -c production
|
||||
|
||||
web-install:
|
||||
cd client/stillbox && npm install
|
||||
|
||||
clean:
|
||||
rm -rf client/calls/ client/stillbox/dist/ client/stillbox/node_modules/
|
||||
|
|
|
@ -61,6 +61,10 @@
|
|||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"index": {
|
||||
"input": "src/index.dev_nocsp.html",
|
||||
"output": "index.html"
|
||||
},
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
|
@ -73,6 +77,9 @@
|
|||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "src/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "stillbox:build:production"
|
||||
|
|
|
@ -43,14 +43,6 @@ export const routes: Routes = [
|
|||
),
|
||||
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',
|
||||
loadComponent: () =>
|
||||
|
@ -65,6 +57,14 @@ export const routes: Routes = [
|
|||
),
|
||||
data: { title: 'Incidents' },
|
||||
},
|
||||
{
|
||||
path: 'incidents/:id',
|
||||
loadComponent: () =>
|
||||
import('./incidents/incident/incident.component').then(
|
||||
(m) => m.IncidentComponent,
|
||||
),
|
||||
data: { title: 'View Incident' },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
loadComponent: () =>
|
||||
|
|
|
@ -83,6 +83,20 @@
|
|||
<button class="sbButton" (click)="refresh()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -109,9 +123,7 @@
|
|||
<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
|
||||
>
|
||||
<call-player [call]="call"></call-player>
|
||||
</td>
|
||||
</ng-container>
|
||||
<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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
@ -29,6 +35,19 @@ import { MatInputModule } from '@angular/material/input';
|
|||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
||||
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({
|
||||
name: 'grabDate',
|
||||
|
@ -139,6 +158,8 @@ const reqPageSize = 200;
|
|||
CommonModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
CallPlayerComponent,
|
||||
MatMenuModule,
|
||||
],
|
||||
templateUrl: './calls.component.html',
|
||||
styleUrl: './calls.component.scss',
|
||||
|
@ -147,6 +168,7 @@ export class CallsComponent {
|
|||
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
|
||||
@ViewChild('paginator') paginator!: MatPaginator;
|
||||
count = 0;
|
||||
dialog = inject(MatDialog);
|
||||
page = 0;
|
||||
perPage = 25;
|
||||
pageSizeOptions = [25, 50, 75, 100, 200];
|
||||
|
@ -189,6 +211,7 @@ export class CallsComponent {
|
|||
private prefsSvc: PrefsService,
|
||||
public tcSvc: ToolbarContextService,
|
||||
public tgSvc: TalkgroupService,
|
||||
public incSvc: IncidentsService,
|
||||
) {
|
||||
this.tcSvc.showFilterButton();
|
||||
}
|
||||
|
@ -243,13 +266,6 @@ export class CallsComponent {
|
|||
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;
|
||||
|
@ -343,4 +359,48 @@ export class CallsComponent {
|
|||
this.form.controls['start'].setValue(this.lTime(new Date()));
|
||||
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({
|
||||
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',
|
||||
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,
|
||||
pure: true,
|
||||
})
|
||||
export class DatePipe implements PipeTransform {
|
||||
transform(ts: string, args?: any): string {
|
||||
export class FmtDatePipe implements PipeTransform {
|
||||
transform(ts: string | Date | null | undefined, args?: any): string {
|
||||
if (!ts) {
|
||||
return '\u2014';
|
||||
}
|
||||
const timestamp = new Date(ts);
|
||||
let timestamp: Date;
|
||||
if (ts instanceof Date) {
|
||||
timestamp = ts;
|
||||
} else {
|
||||
timestamp = new Date(ts);
|
||||
}
|
||||
return (
|
||||
timestamp.getMonth() +
|
||||
1 +
|
||||
|
@ -61,7 +66,7 @@ const reqPageSize = 200;
|
|||
selector: 'app-incidents',
|
||||
imports: [
|
||||
MatIconModule,
|
||||
DatePipe,
|
||||
FmtDatePipe,
|
||||
MatPaginatorModule,
|
||||
MatTableModule,
|
||||
AsyncPipe,
|
||||
|
|
|
@ -47,4 +47,8 @@ export class IncidentsService {
|
|||
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
|
||||
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 {
|
||||
return iconMapping[icon]!;
|
||||
}
|
||||
|
||||
tgTuple(): TGID {
|
||||
return <TGID>{
|
||||
sys: this.system_id,
|
||||
tg: this.tgid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface TalkgroupUI extends Talkgroup {
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<h2 mat-dialog-title>Edit {{ tgid.sys }}:{{ tgid.tg }}</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="tgRecord">
|
||||
<form *ngIf="tg" [formGroup]="form" (ngSubmit)="submit()">
|
||||
@let tg = tg$ | async;
|
||||
@if (tg) {
|
||||
<form id="tgForm" [formGroup]="form" (ngSubmit)="save()">
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
|
@ -28,7 +32,7 @@
|
|||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<div class="twoRow">
|
||||
<mat-form-field>
|
||||
<mat-label>Frequency</mat-label
|
||||
><input
|
||||
|
@ -38,11 +42,14 @@
|
|||
formControlName="frequency"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<mat-label>Weight</mat-label
|
||||
><input matInput name="weight" type="text" formControlName="weight" />
|
||||
><input
|
||||
matInput
|
||||
name="weight"
|
||||
type="text"
|
||||
formControlName="weight"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -95,14 +102,23 @@
|
|||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-label>Alert</mat-label>
|
||||
<mat-checkbox name="alert" formControlName="alert" />
|
||||
<div class="alert">
|
||||
<mat-checkbox name="alert" formControlName="alert"
|
||||
>Alert</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
Rules:
|
||||
<div class="alert">
|
||||
<alert-rule-builder [rules]="tg.alert_config" />
|
||||
</div>
|
||||
<button class="sbButton" type="submit">Save</button>
|
||||
</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="tgForm" type="submit" cdkFocusInitial>Save</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
|
@ -1,9 +1,24 @@
|
|||
mat-form-field {
|
||||
form div {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 31rem;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Component, computed, inject, ViewChild } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { debounceTime, tap } from 'rxjs/operators';
|
||||
import {
|
||||
Talkgroup,
|
||||
TalkgroupUpdate,
|
||||
IconMap,
|
||||
iconMapping,
|
||||
TGID,
|
||||
} from '../../talkgroup';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { TalkgroupService } from '../talkgroups.service';
|
||||
|
@ -17,7 +18,7 @@ import {
|
|||
MatAutocompleteActivatedEvent,
|
||||
} from '@angular/material/autocomplete';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BehaviorSubject, catchError, of, Subscription } from 'rxjs';
|
||||
import { catchError, of, Subscription } from 'rxjs';
|
||||
import { shareReplay } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
|
@ -26,12 +27,21 @@ import {
|
|||
FormControl,
|
||||
FormsModule,
|
||||
} from '@angular/forms';
|
||||
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';
|
||||
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
|
||||
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({
|
||||
selector: 'talkgroup-record',
|
||||
|
@ -46,11 +56,19 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatAutocompleteModule,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
],
|
||||
templateUrl: './talkgroup-record.component.html',
|
||||
styleUrl: './talkgroup-record.component.scss',
|
||||
})
|
||||
export class TalkgroupRecordComponent {
|
||||
dialogRef = inject(MatDialogRef<TalkgroupRecordComponent>);
|
||||
tgid = inject<TGID>(MAT_DIALOG_DATA);
|
||||
tg$!: Observable<Talkgroup>;
|
||||
tg!: Talkgroup;
|
||||
iconMapping: IconMap = iconMapping;
|
||||
tgService: TalkgroupService = inject(TalkgroupService);
|
||||
|
@ -82,10 +100,7 @@ export class TalkgroupRecordComponent {
|
|||
active: string | null = null;
|
||||
subscriptions = new Subscription();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
) {
|
||||
constructor() {
|
||||
this._allTags = this.tgService.allTags().pipe(shareReplay());
|
||||
}
|
||||
|
||||
|
@ -138,25 +153,16 @@ export class TalkgroupRecordComponent {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
const sysId = this.route.snapshot.paramMap.get('sys');
|
||||
const tgId = this.route.snapshot.paramMap.get('tg');
|
||||
|
||||
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.tg$ = this.tgService
|
||||
.getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg))
|
||||
.pipe(
|
||||
tap((tg) => {
|
||||
this.form.patchValue(tg);
|
||||
this.form.controls['tagInput'].setValue('');
|
||||
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
|
||||
}),
|
||||
);
|
||||
|
||||
this.subscriptions.add(
|
||||
this._allTags.subscribe((event) => {
|
||||
this.allTags = event;
|
||||
|
@ -168,11 +174,10 @@ export class TalkgroupRecordComponent {
|
|||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
submit() {
|
||||
save() {
|
||||
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
|
||||
system_id: this.tg.system_id,
|
||||
tgid: this.tg.tgid,
|
||||
id: this.tg.id,
|
||||
system_id: this.tgid.sys,
|
||||
tgid: this.tgid.tg,
|
||||
};
|
||||
if (this.form.controls['name'].dirty) {
|
||||
tgu.name = this.form.controls['name'].value;
|
||||
|
@ -208,6 +213,7 @@ export class TalkgroupRecordComponent {
|
|||
});
|
||||
}
|
||||
}
|
||||
this.subscriptions.add(
|
||||
this.tgService
|
||||
.putTalkgroup(tgu)
|
||||
.pipe(
|
||||
|
@ -215,8 +221,13 @@ export class TalkgroupRecordComponent {
|
|||
return of(null);
|
||||
}),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.router.navigate(['/talkgroups/']);
|
||||
});
|
||||
.subscribe((newTG) => {
|
||||
this.dialogRef.close(newTG);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,8 +64,8 @@
|
|||
</ng-container>
|
||||
<ng-container matColumnDef="edit">
|
||||
<th mat-header-cell *matHeaderCellDef>Edit</th>
|
||||
<td mat-cell *matCellDef="let tg">
|
||||
<a routerLink="/talkgroups/{{ tg.system?.id }}/{{ tg.tgid }}"
|
||||
<td mat-cell *matCellDef="let tg; let i = index">
|
||||
<a (click)="editTG(i, tg.system?.id, tg.tgid)"
|
||||
><mat-icon>edit</mat-icon>
|
||||
</a>
|
||||
</td>
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { TalkgroupService, TalkgroupsPaginated } from '../talkgroups.service';
|
||||
import { Talkgroup, iconMapping } from '../../talkgroup';
|
||||
import { TGID, Talkgroup, iconMapping } from '../../talkgroup';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { RouterModule, RouterLink } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
@ -27,6 +27,16 @@ import { MatChipsModule } from '@angular/material/chips';
|
|||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
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({
|
||||
standalone: true,
|
||||
|
@ -59,7 +69,6 @@ export class SanitizeHtmlPipe implements PipeTransform {
|
|||
selector: 'talkgroup-table',
|
||||
imports: [
|
||||
RouterModule,
|
||||
RouterLink,
|
||||
MatIconModule,
|
||||
CommonModule,
|
||||
IconifyPipe,
|
||||
|
@ -104,6 +113,7 @@ export class TalkgroupTableComponent {
|
|||
@Input() resetPage!: Observable<void>;
|
||||
@ViewChild('paginator') paginator!: MatPaginator;
|
||||
suppress = false;
|
||||
dialog = inject(MatDialog);
|
||||
|
||||
constructor(private route: ActivatedRoute) {}
|
||||
|
||||
|
@ -156,4 +166,18 @@ export class TalkgroupTableComponent {
|
|||
? this.selection.clear()
|
||||
: 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[]> {
|
||||
return this.http.get<Talkgroup[]>('/api/talkgroup/');
|
||||
return this.http.get<Talkgroup[]>('/api/talkgroup/').pipe(shareReplay());
|
||||
}
|
||||
|
||||
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
|
||||
|
@ -64,7 +64,7 @@ export class TalkgroupService {
|
|||
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
|
||||
let tgid = this.tgKey(tu.system_id, tu.tgid);
|
||||
|
||||
this.http
|
||||
return this.http
|
||||
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
|
||||
.pipe(
|
||||
switchMap((tg) => {
|
||||
|
@ -78,8 +78,6 @@ export class TalkgroupService {
|
|||
return tObs;
|
||||
}),
|
||||
);
|
||||
|
||||
return this._getTalkgroup.get(tgid)!;
|
||||
}
|
||||
|
||||
putTalkgroups(
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
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
|
||||
// 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();
|
||||
|
||||
|
@ -222,7 +223,8 @@ body {
|
|||
width: 100px;
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
textarea {
|
||||
caret-color: var(--color-dark-fg) !important;
|
||||
}
|
||||
|
||||
|
@ -236,3 +238,8 @@ input {
|
|||
margin-bottom: 40px;
|
||||
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
|
||||
allowInsecureFor:
|
||||
"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'
|
||||
public: true
|
||||
log:
|
||||
|
|
|
@ -32,7 +32,7 @@ func call(url string, call *calls.Call) error {
|
|||
var buf bytes.Buffer
|
||||
body := multipart.NewWriter(&buf)
|
||||
|
||||
err := forms.Marshal(call, body, forms.WithTag("json"))
|
||||
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("relay form parse: %w", err)
|
||||
}
|
||||
|
|
|
@ -145,6 +145,16 @@ func (a *Auth) allowInsecureCookie(r *http.Request) bool {
|
|||
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) {
|
||||
jwToken, _, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil {
|
||||
|
@ -174,8 +184,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if a.allowInsecureCookie(r) {
|
||||
cookie.Secure = false
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
a.setInsecureCookie(cookie)
|
||||
}
|
||||
|
||||
if cookie.Secure {
|
||||
|
@ -236,8 +245,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
cookie.Domain = r.Host
|
||||
if a.allowInsecureCookie(r) {
|
||||
cookie.Secure = false
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
a.setInsecureCookie(cookie)
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
|
@ -263,8 +271,8 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
cookie.Domain = r.Host
|
||||
if a.allowInsecureCookie(r) {
|
||||
cookie.Secure = false
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
cookie.Secure = true
|
||||
cookie.SameSite = http.SameSiteNoneMode
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package calls
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
@ -29,6 +30,10 @@ func (d CallDuration) MsInt32Ptr() *int32 {
|
|||
return &i
|
||||
}
|
||||
|
||||
func (d CallDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.Duration().Milliseconds())
|
||||
}
|
||||
|
||||
func (d CallDuration) Seconds() int32 {
|
||||
return int32(time.Duration(d).Seconds())
|
||||
}
|
||||
|
@ -41,24 +46,29 @@ type CallAudio struct {
|
|||
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 {
|
||||
ID uuid.UUID `json:"-"`
|
||||
Audio []byte `json:"audio,omitempty" filenameField:"AudioName"`
|
||||
AudioName string `json:"audioName,omitempty"`
|
||||
AudioType string `json:"audioType,omitempty"`
|
||||
Duration CallDuration `json:"-"`
|
||||
DateTime time.Time `json:"dateTime,omitempty"`
|
||||
Frequencies []int `json:"frequencies,omitempty"`
|
||||
Frequency int `json:"frequency,omitempty"`
|
||||
Patches []int `json:"patches,omitempty"`
|
||||
Source int `json:"source,omitempty"`
|
||||
System int `json:"system,omitempty"`
|
||||
Submitter *auth.UserID `json:"-,omitempty"`
|
||||
SystemLabel string `json:"systemLabel,omitempty"`
|
||||
Talkgroup int `json:"talkgroup,omitempty"`
|
||||
TalkgroupGroup *string `json:"talkgroupGroup,omitempty"`
|
||||
TalkgroupLabel *string `json:"talkgroupLabel,omitempty"`
|
||||
TGAlphaTag *string `json:"talkgroupTag,omitempty"`
|
||||
ID uuid.UUID `json:"id" relayOut:"id"`
|
||||
Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"`
|
||||
AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"`
|
||||
AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"`
|
||||
Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"`
|
||||
DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"`
|
||||
Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"`
|
||||
Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"`
|
||||
Patches []int `json:"patches,omitempty" relayOut:"patches,omitempty"`
|
||||
Source int `json:"source,omitempty" relayOut:"source,omitempty"`
|
||||
System int `json:"system_id,omitempty" relayOut:"system,omitempty"`
|
||||
Submitter *auth.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"`
|
||||
SystemLabel string `json:"system_name,omitempty" relayOut:"systemLabel,omitempty"`
|
||||
Talkgroup int `json:"tgid,omitempty" relayOut:"talkgroup,omitempty"`
|
||||
TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,omitempty"`
|
||||
TalkgroupLabel *string `json:"talkgroupLabel,omitempty" relayOut:"talkgroupLabel,omitempty"`
|
||||
TGAlphaTag *string `json:"tg_name,omitempty" relayOut:"talkgroupTag,omitempty"`
|
||||
|
||||
shouldStore bool `json:"-"`
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ type Config struct {
|
|||
type Auth struct {
|
||||
JWTSecret string `yaml:"jwtsecret"`
|
||||
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
|
||||
SameSiteNoneWhenInsecure bool `yaml:"sameSiteNoneForInsecure"`
|
||||
}
|
||||
|
||||
type CORS struct {
|
||||
|
|
|
@ -147,7 +147,6 @@ WITH to_sweep AS (
|
|||
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) {
|
||||
result, err := q.db.Exec(ctx, cleanupSweptCalls, rangeStart, rangeEnd)
|
||||
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
|
||||
`
|
||||
|
||||
// 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) {
|
||||
result, err := q.db.Exec(ctx, sweepCalls, rangeStart, rangeEnd)
|
||||
if err != nil {
|
||||
|
|
|
@ -132,7 +132,22 @@ func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, erro
|
|||
}
|
||||
|
||||
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 (
|
||||
SELECT
|
||||
ca.submitter,
|
||||
|
@ -170,12 +185,12 @@ WHERE ic.incident_id = $1
|
|||
type GetIncidentCallsRow struct {
|
||||
CallID uuid.UUID `json:"call_id"`
|
||||
CallDate pgtype.Timestamptz `json:"call_date"`
|
||||
Duration *int32 `json:"duration"`
|
||||
SystemID int `json:"system_id"`
|
||||
TGID int `json:"tgid"`
|
||||
Notes []byte `json:"notes"`
|
||||
Submitter *int32 `json:"submitter"`
|
||||
System int `json:"system"`
|
||||
Talkgroup int `json:"talkgroup"`
|
||||
AudioName *string `json:"audio_name"`
|
||||
Duration *int32 `json:"duration"`
|
||||
AudioType *string `json:"audio_type"`
|
||||
AudioUrl *string `json:"audio_url"`
|
||||
Frequency int `json:"frequency"`
|
||||
|
@ -197,12 +212,12 @@ func (q *Queries) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetInci
|
|||
if err := rows.Scan(
|
||||
&i.CallID,
|
||||
&i.CallDate,
|
||||
&i.Duration,
|
||||
&i.SystemID,
|
||||
&i.TGID,
|
||||
&i.Notes,
|
||||
&i.Submitter,
|
||||
&i.System,
|
||||
&i.Talkgroup,
|
||||
&i.AudioName,
|
||||
&i.Duration,
|
||||
&i.AudioType,
|
||||
&i.AudioUrl,
|
||||
&i.Frequency,
|
||||
|
|
|
@ -16,7 +16,6 @@ type Querier interface {
|
|||
AddCall(ctx context.Context, arg AddCallParams) error
|
||||
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, 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)
|
||||
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, 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
|
||||
StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error
|
||||
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)
|
||||
UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error
|
||||
UpdateIncident(ctx context.Context, arg UpdateIncidentParams) (Incident, error)
|
||||
|
|
|
@ -240,9 +240,9 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
|
|||
Frequency: v.Frequency,
|
||||
Patches: v.Patches,
|
||||
Source: v.Source,
|
||||
System: v.System,
|
||||
System: v.SystemID,
|
||||
Submitter: sub,
|
||||
Talkgroup: v.Talkgroup,
|
||||
Talkgroup: v.TGID,
|
||||
},
|
||||
Notes: v.Notes,
|
||||
})
|
||||
|
|
|
@ -81,7 +81,7 @@ func (s *Relay) Call(ctx context.Context, call *calls.Call) error {
|
|||
var buf bytes.Buffer
|
||||
body := multipart.NewWriter(&buf)
|
||||
|
||||
err := forms.Marshal(call, body, forms.WithTag("json"))
|
||||
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
|
||||
if err != nil {
|
||||
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
|
||||
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 (
|
||||
SELECT
|
||||
ca.submitter,
|
||||
|
|
Loading…
Reference in a new issue