diff --git a/Makefile b/Makefile index a0a54b2..a48dec7 100644 --- a/Makefile +++ b/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/ diff --git a/client/stillbox/angular.json b/client/stillbox/angular.json index e130a69..7807679 100644 --- a/client/stillbox/angular.json +++ b/client/stillbox/angular.json @@ -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" diff --git a/client/stillbox/src/app/app.routes.ts b/client/stillbox/src/app/app.routes.ts index d1a67e2..9898a2e 100644 --- a/client/stillbox/src/app/app.routes.ts +++ b/client/stillbox/src/app/app.routes.ts @@ -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: () => diff --git a/client/stillbox/src/app/calls/calls.component.html b/client/stillbox/src/app/calls/calls.component.html index 67a38fd..100cc73 100644 --- a/client/stillbox/src/app/calls/calls.component.html +++ b/client/stillbox/src/app/calls/calls.component.html @@ -83,6 +83,20 @@ + + + + + @@ -109,9 +123,7 @@ - play_arrow + diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts index 030f217..f6432c1 100644 --- a/client/stillbox/src/app/calls/calls.component.ts +++ b/client/stillbox/src/app/calls/calls.component.ts @@ -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(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: { + incID: '', + new: true, + }, + }); + dialogRef.afterClosed().subscribe((res: IncidentRecord) => { + this.incSvc + .addRemoveCalls(res.id, { + 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, { + add: this.selection.selected.map((s) => s.id), + }) + .subscribe({ + next: () => { + this.selection.clear(); + }, + error: (err) => { + alert(err); + }, + }); + }); + } } diff --git a/client/stillbox/src/app/calls/player/call-player/call-player.component.html b/client/stillbox/src/app/calls/player/call-player/call-player.component.html new file mode 100644 index 0000000..1aab7d8 --- /dev/null +++ b/client/stillbox/src/app/calls/player/call-player/call-player.component.html @@ -0,0 +1,9 @@ +@if (playing) { + stop +} @else { + play_arrow +} diff --git a/client/stillbox/src/app/calls/player/call-player/call-player.component.scss b/client/stillbox/src/app/calls/player/call-player/call-player.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/stillbox/src/app/calls/player/call-player/call-player.component.spec.ts b/client/stillbox/src/app/calls/player/call-player/call-player.component.spec.ts new file mode 100644 index 0000000..1a06ffb --- /dev/null +++ b/client/stillbox/src/app/calls/player/call-player/call-player.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CallPlayerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CallPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/calls/player/call-player/call-player.component.ts b/client/stillbox/src/app/calls/player/call-player/call-player.component.ts new file mode 100644 index 0000000..2b528bc --- /dev/null +++ b/client/stillbox/src/app/calls/player/call-player/call-player.component.ts @@ -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); + }); + } +} diff --git a/client/stillbox/src/app/incidents/incident/incident-editor-dialog.component.html b/client/stillbox/src/app/incidents/incident/incident-editor-dialog.component.html new file mode 100644 index 0000000..f1f01d9 --- /dev/null +++ b/client/stillbox/src/app/incidents/incident/incident-editor-dialog.component.html @@ -0,0 +1,56 @@ +

{{ title }}

+ +
+ @let inc = inc$ | async; + @if (inc) { +
+
+ + Name + + + + Start + + + + End + + + + Description + + +
+
+ } @else { +
+ +
+ } +
+
+ + + + diff --git a/client/stillbox/src/app/incidents/incident/incident.component.html b/client/stillbox/src/app/incidents/incident/incident.component.html index 5afe3dc..b60261d 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.html +++ b/client/stillbox/src/app/incidents/incident/incident.component.html @@ -1 +1,98 @@ -

incident works!

+@let inc = inc$ | async; + +
+

{{ inc?.name }}

+ + + + + +
+
+
Start
+
+ {{ inc?.startTime | fmtDate }} +
+
End
+
{{ inc?.endTime | fmtDate }}
+
+
+ {{ inc?.description }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + download + Date + {{ call.call_date | grabDate }} + Time + {{ call.call_date | time }} + System + {{ call | talkgroup: "system" | async }} + Group + {{ call | talkgroup: "group" | async }} + Talkgroup + {{ call | talkgroup: "alpha" | async }} + Duration + {{ call.duration | fixedPoint: 1000 : 2 }}s +
+
diff --git a/client/stillbox/src/app/incidents/incident/incident.component.scss b/client/stillbox/src/app/incidents/incident/incident.component.scss index e69de29..08e0820 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.scss +++ b/client/stillbox/src/app/incidents/incident/incident.component.scss @@ -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; +} diff --git a/client/stillbox/src/app/incidents/incident/incident.component.ts b/client/stillbox/src/app/incidents/incident/incident.component.ts index 27d0508..944fe04 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.ts +++ b/client/stillbox/src/app/incidents/incident/incident.component.ts @@ -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); + data = inject(MAT_DIALOG_DATA); + title = this.data.new ? 'New Incident' : 'Edit Incident'; + inc$!: Observable; + 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({}); + } + } + + save() { + let resObs: Observable; + let ir: 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({}); + inc$!: Observable; + subscriptions: Subscription = new Subscription(); + dialog = inject(MatDialog); + incID!: string; + columns = [ + 'select', + 'play', + 'download', + 'date', + 'time', + 'system', + 'group', + 'talkgroup', + 'duration', + ]; + callsResult = new MatTableDataSource(); + selection = new SelectionModel(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: { + 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)); + } +} diff --git a/client/stillbox/src/app/incidents/incidents.component.ts b/client/stillbox/src/app/incidents/incidents.component.ts index 06624aa..8795eeb 100644 --- a/client/stillbox/src/app/incidents/incidents.component.ts +++ b/client/stillbox/src/app/incidents/incidents.component.ts @@ -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, diff --git a/client/stillbox/src/app/incidents/incidents.service.ts b/client/stillbox/src/app/incidents/incidents.service.ts index e432914..6244052 100644 --- a/client/stillbox/src/app/incidents/incidents.service.ts +++ b/client/stillbox/src/app/incidents/incidents.service.ts @@ -47,4 +47,8 @@ export class IncidentsService { updateIncident(id: string, inp: IncidentRecord): Observable { return this.http.patch('/api/incident/' + id, inp); } + + getIncident(id: string): Observable { + return this.http.get('/api/incident/' + id); + } } diff --git a/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.html b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.html new file mode 100644 index 0000000..a5c2b54 --- /dev/null +++ b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.html @@ -0,0 +1,23 @@ +

Select Incident

+ + + + +
+ + @let incidents = incs$ | async; + @for (inc of incidents; track inc) { + {{ inc.name }} + } + +
+
+ + + + diff --git a/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.scss b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.scss new file mode 100644 index 0000000..9174840 --- /dev/null +++ b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.scss @@ -0,0 +1,5 @@ +.selList { + overflow: scroll; + width: 480px; + min-height: 200px; +} diff --git a/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.spec.ts b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.spec.ts new file mode 100644 index 0000000..f11865b --- /dev/null +++ b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectIncidentDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectIncidentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.ts b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.ts new file mode 100644 index 0000000..ecab136 --- /dev/null +++ b/client/stillbox/src/app/incidents/select-incident-dialog/select-incident-dialog.component.ts @@ -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); + allIncs$!: Observable; + incs$!: Observable; + findStr = new BehaviorSubject(''); + sel!: string; + constructor(private incSvc: IncidentsService) {} + + ngOnInit() { + this.incs$ = this.findStr.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((sub) => + this.incSvc.getIncidents({ filter: sub }), + ), + map((incidents) => incidents.incidents), + ); + } + + search(term: string) { + this.findStr.next(term); + } + + cancel() { + this.dialogRef.close(null); + } + + save() { + this.dialogRef.close(this.sel); + } +} diff --git a/client/stillbox/src/app/talkgroup.ts b/client/stillbox/src/app/talkgroup.ts index 97469ac..475d810 100644 --- a/client/stillbox/src/app/talkgroup.ts +++ b/client/stillbox/src/app/talkgroup.ts @@ -89,6 +89,13 @@ export class Talkgroup { iconMap(icon: string): string { return iconMapping[icon]!; } + + tgTuple(): TGID { + return { + sys: this.system_id, + tg: this.tgid, + }; + } } export interface TalkgroupUI extends Talkgroup { diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html index 9676174..8af8e85 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html @@ -1,108 +1,124 @@ -
-
-
- - Name - - -
-
- - Alpha Tag - -
-
- - Group - -
-
- - Frequency - -
-
- - Weight - -
-
- - Icon - + +
+
+ + Alpha Tag + +
+
+ + Group + +
+
+ + Frequency + + + Weight + +
+
+ + Icon + + +
+
+ + Tags + + @for (tag of tg.tags; track tag) { + + {{ tag }} + + + } + + + - {{ opt.key | titlecase }} - - } - - -
-
- - Tags - - @for (tag of tg.tags; track tag) { - - {{ tag }} - - - } - - - - @for (tag of filteredTags(); track tag) { - {{ tag }} - } - - -
-
- Alert - -
-
- Rules: - -
- -
-
+ @for (tag of filteredTags(); track tag) { + {{ tag }} + } + + + +
+ Alert +
+
+ +
+ + } @else { +
+ +
+ } + + + + + + diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.scss b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.scss index c97ed42..5981fc8 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.scss +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.scss @@ -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; } diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts index 27ea769..60e0504 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts @@ -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); + tgid = inject(MAT_DIALOG_DATA); + tg$!: Observable; 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 = { - 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,15 +213,21 @@ export class TalkgroupRecordComponent { }); } } - this.tgService - .putTalkgroup(tgu) - .pipe( - catchError(() => { - return of(null); + this.subscriptions.add( + this.tgService + .putTalkgroup(tgu) + .pipe( + catchError(() => { + return of(null); + }), + ) + .subscribe((newTG) => { + this.dialogRef.close(newTG); }), - ) - .subscribe((event) => { - this.router.navigate(['/talkgroups/']); - }); + ); + } + + cancel() { + this.dialogRef.close(); } } diff --git a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html index 505dcf0..780f1b3 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html +++ b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html @@ -64,8 +64,8 @@
Edit - - + edit diff --git a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.ts b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.ts index d95f529..602dd9f 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.ts +++ b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.ts @@ -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; @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 = { 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; + } + }); + } } diff --git a/client/stillbox/src/app/talkgroups/talkgroups.service.ts b/client/stillbox/src/app/talkgroups/talkgroups.service.ts index 76be1c3..c309d01 100644 --- a/client/stillbox/src/app/talkgroups/talkgroups.service.ts +++ b/client/stillbox/src/app/talkgroups/talkgroups.service.ts @@ -46,7 +46,7 @@ export class TalkgroupService { } getTalkgroups(): Observable { - return this.http.get('/api/talkgroup/'); + return this.http.get('/api/talkgroup/').pipe(shareReplay()); } getTalkgroup(sys: number, tg: number): Observable { @@ -64,7 +64,7 @@ export class TalkgroupService { putTalkgroup(tu: TalkgroupUpdate): Observable { let tgid = this.tgKey(tu.system_id, tu.tgid); - this.http + return this.http .put(`/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( diff --git a/client/stillbox/src/environments/environment.development.ts b/client/stillbox/src/environments/environment.development.ts index 247eb12..e67cb31 100644 --- a/client/stillbox/src/environments/environment.development.ts +++ b/client/stillbox/src/environments/environment.development.ts @@ -1,3 +1,3 @@ export const environment = { - baseUrl: 'http://xenon:3050', + baseUrl: '', }; diff --git a/client/stillbox/src/index.dev_nocsp.html b/client/stillbox/src/index.dev_nocsp.html new file mode 100644 index 0000000..a20dc26 --- /dev/null +++ b/client/stillbox/src/index.dev_nocsp.html @@ -0,0 +1,19 @@ + + + + + Stillbox + + + + + + + + + + + + diff --git a/client/stillbox/src/proxy.conf.json b/client/stillbox/src/proxy.conf.json new file mode 100644 index 0000000..96af567 --- /dev/null +++ b/client/stillbox/src/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://xenon:3050", + "secure": false + } +} diff --git a/client/stillbox/src/styles.scss b/client/stillbox/src/styles.scss index bb21ba1..c0d1767 100644 --- a/client/stillbox/src/styles.scss +++ b/client/stillbox/src/styles.scss @@ -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; +} diff --git a/config.sample.yaml b/config.sample.yaml index 11e365c..8a4ee2d 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -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: diff --git a/internal/forms/marshal_test.go b/internal/forms/marshal_test.go index b4aa217..ae624c2 100644 --- a/internal/forms/marshal_test.go +++ b/internal/forms/marshal_test.go @@ -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) } diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 6cb64f3..b2eed4c 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -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) diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 5c784cb..effdbce 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -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:"-"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index bbea959..61dc344 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,8 +30,9 @@ type Config struct { } type Auth struct { - JWTSecret string `yaml:"jwtsecret"` - AllowInsecure map[string]bool `yaml:"allowInsecureFor"` + JWTSecret string `yaml:"jwtsecret"` + AllowInsecure map[string]bool `yaml:"allowInsecureFor"` + SameSiteNoneWhenInsecure bool `yaml:"sameSiteNoneForInsecure"` } type CORS struct { diff --git a/pkg/database/calls.sql.go b/pkg/database/calls.sql.go index 1129b5e..a58588f 100644 --- a/pkg/database/calls.sql.go +++ b/pkg/database/calls.sql.go @@ -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 { diff --git a/pkg/database/incidents.sql.go b/pkg/database/incidents.sql.go index a49f5aa..28a46a2 100644 --- a/pkg/database/incidents.sql.go +++ b/pkg/database/incidents.sql.go @@ -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, diff --git a/pkg/database/querier.go b/pkg/database/querier.go index f8f15e2..4ef8f5e 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -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) diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 79785ed..0a4ba6e 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -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, }) diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index b586345..e69f5f2 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -56,7 +56,7 @@ func (ia *incidentsAPI) listIncidents(w http.ResponseWriter, r *http.Request) { res := struct { Incidents []incstore.Incident `json:"incidents"` - Count int `json:"count"` + Count int `json:"count"` }{} res.Incidents, res.Count, err = incs.Incidents(ctx, p) diff --git a/pkg/sinks/relay.go b/pkg/sinks/relay.go index 1c75db1..00b05a8 100644 --- a/pkg/sinks/relay.go +++ b/pkg/sinks/relay.go @@ -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) } diff --git a/sql/postgres/queries/incidents.sql b/sql/postgres/queries/incidents.sql index 16dde5c..f504919 100644 --- a/sql/postgres/queries/incidents.sql +++ b/sql/postgres/queries/incidents.sql @@ -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,