Compare commits

...

27 commits

Author SHA1 Message Date
29054e8242 beore preline 2024-11-08 18:41:35 -05:00
50109bc283 sidebar 2024-11-08 18:41:35 -05:00
4bd8f3351a remove notifications 2024-11-08 18:41:35 -05:00
951e0459b8 wip 2024-11-08 18:41:35 -05:00
2ec7924e86 Fix login form 2024-11-08 18:41:35 -05:00
558a57e797 before coreui 2024-11-08 18:41:35 -05:00
6e0aade9d2 kindof working 2024-11-08 18:41:35 -05:00
c891a94c7b #28 per backend notification templates 2024-11-08 18:41:35 -05:00
21eabbc17a Clean up tg ID stuff 2024-11-08 18:41:35 -05:00
ab3219c434 Make clear that it is milliseconds 2024-11-08 18:41:35 -05:00
5ea44aebb9 UUID is part of Call and is generated by us.
Closes #8
2024-11-08 18:41:35 -05:00
f3364a93ec Change ws route 2024-11-08 18:41:35 -05:00
81135c29f0 Change login route, add form 2024-11-08 18:41:35 -05:00
6556c8049c Don't apply talkgroup weight twice
Also tag ID fields
2024-11-08 18:41:35 -05:00
08f1bd4ff0 API begin 2024-11-08 18:41:35 -05:00
58c1376146 wip 2024-11-08 18:41:35 -05:00
465e86a6bb Revert weight during score 2024-11-08 18:41:35 -05:00
10e4eff17a Use mapstructure for notify config 2024-11-08 18:41:35 -05:00
b40144447f Actually return multiplier 2024-11-08 18:41:35 -05:00
092b925a25 Don't apply multiplier twice 2024-11-08 18:41:35 -05:00
28ee194297 Scale scores during score operation.
Closes #24.
2024-11-08 18:41:35 -05:00
5bce1f4f9d Change error name 2024-11-08 18:41:35 -05:00
77cdacc917 Granular locking 2024-11-08 18:41:35 -05:00
7242782d39 Embed 2024-11-08 18:41:35 -05:00
8afc687d4f Use sqlc.embed 2024-11-08 18:41:35 -05:00
1f8fe24dd2 Reorg 2024-11-08 18:41:35 -05:00
8d32757334 Split out talkgroups 2024-11-08 18:41:35 -05:00
66 changed files with 2000 additions and 854 deletions

View file

@ -8,9 +8,8 @@ describe('AlertsComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AlertsComponent] imports: [AlertsComponent],
}) }).compileComponents();
.compileComponents();
fixture = TestBed.createComponent(AlertsComponent); fixture = TestBed.createComponent(AlertsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View file

@ -5,8 +5,6 @@ import { Component } from '@angular/core';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './alerts.component.html', templateUrl: './alerts.component.html',
styleUrl: './alerts.component.css' styleUrl: './alerts.component.css',
}) })
export class AlertsComponent { export class AlertsComponent {}
}

View file

@ -0,0 +1,3 @@
.stillboxLogo {
font-family: "Warnes";
}

View file

@ -1,63 +1,266 @@
<!-- Navbar --> <!-- ========== HEADER ========== -->
<nav class="navbar justify-between bg-base-300"> @if (auth.loggedIn) {
<header
class="sticky top-0 inset-x-0 flex flex-wrap md:justify-start md:flex-nowrap z-[48] w-full bg-white border-b text-sm py-2.5 lg:ps-[260px] dark:bg-neutral-800 dark:border-neutral-700"
>
<nav class="px-4 sm:px-6 flex basis-full items-center w-full mx-auto">
<div class="me-5 lg:me-0 lg:hidden">
<!-- Logo --> <!-- Logo -->
<a class="btn btn-ghost text-lg"> <a
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> class="flex-none rounded-md text-xl inline-block stillboxLogo focus:outline-none focus:opacity-80"
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z" /> href="#"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z" /> aria-label="Stillbox"
</svg> >
Stillbox
</a> </a>
<!-- End Logo -->
<!-- Menu for mobile --> </div>
<div class="dropdown dropdown-end sm:hidden"> <div
<button class="btn btn-ghost"> class="w-full flex items-center justify-end ms-auto md:justify-between gap-x-1 md:gap-x-3"
<i class="fa-solid fa-bars text-lg"></i> >
<div class="hidden md:block">
<!-- Search Input -->
<div class="relative">
<div
class="absolute inset-y-0 start-0 flex items-center pointer-events-none z-20 ps-3.5"
>
<svg
class="shrink-0 size-4 text-gray-400 dark:text-white/60"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</div>
<input
type="text"
class="py-2 ps-10 pe-16 block w-full bg-white border-gray-200 rounded-lg text-sm focus:outline-none focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder:text-neutral-400 dark:focus:ring-neutral-600"
placeholder="Search"
/>
<div
class="hidden absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-1"
>
<button
type="button"
class="inline-flex shrink-0 justify-center items-center size-6 rounded-full text-gray-500 hover:text-blue-600 focus:outline-none focus:text-blue-600 dark:text-neutral-500 dark:hover:text-blue-500 dark:focus:text-blue-500"
aria-label="Close"
>
<span class="sr-only">Close</span>
<svg
class="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
</button> </button>
</div>
<ul tabindex="0" class="dropdown-content menu z-[1] bg-base-200 p-6 rounded-box shadow w-56 gap-2"> <div
<li><a>Item</a></li> class="absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-3 text-gray-400"
<a class="btn btn-sm btn-primary">Do</a> >
</ul> <span class="mx-1"> </span>
<span class="text-xs">/</span>
</div>
</div>
<!-- End Search Input -->
</div> </div>
<!-- Menu for desktop --> <div class="flex flex-row items-center justify-end gap-1">
<ul class="hidden menu sm:menu-horizontal gap-2"> <button
<li><a>Item</a></li> type="button"
<a class="btn btn-sm btn-primary">Do</a> class="md:hidden size-[38px] relative inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-full border border-transparent text-gray-800 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700"
>
<svg
class="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span class="sr-only">Search</span>
</button>
<a (click)="logout()" class="btn btn-sm btn-primary">Logout</a>
</div>
</div>
</nav>
</header>
} @else {
<div class="relative flex flex-col h-full max-h-full">
<div class="px-6 pt-4">
<!-- Logo -->
<a
class="stillboxLogo flex-none rounded-xl text-xl inline-block font-semibold focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
</div>
}
<!-- ========== END HEADER ========== -->
@if (auth.loggedIn) {
<div class="-mt-px">
<!-- Breadcrumb -->
<div class="sticky top-0 inset-x-0 z-20 bg-white border-y px-4 sm:px-6 lg:px-8 lg:hidden dark:bg-neutral-800 dark:border-neutral-700">
<div class="flex items-center py-2">
<!-- Navigation Toggle -->
<button type="button" class="size-8 flex justify-center items-center gap-x-2 border border-gray-200 text-gray-800 hover:text-gray-500 rounded-lg focus:outline-none focus:text-gray-500 disabled:opacity-50 disabled:pointer-events-none dark:border-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-500 dark:focus:text-neutral-500" aria-haspopup="dialog" aria-expanded="false" aria-controls="hs-application-sidebar" aria-label="Toggle navigation" data-hs-overlay="#hs-application-sidebar">
<span class="sr-only">Toggle Navigation</span>
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>
</button>
<!-- End Navigation Toggle -->
<!-- Breadcrumb -->
<ol class="ms-3 flex items-center whitespace-nowrap">
<li class="flex items-center text-sm text-gray-800 dark:text-neutral-400">
Application Layout
<svg class="shrink-0 mx-3 overflow-visible size-2.5 text-gray-400 dark:text-neutral-500" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</li>
<li class="text-sm font-semibold text-gray-800 truncate dark:text-neutral-400" aria-current="page">
Dashboard
</li>
</ol>
<!-- End Breadcrumb -->
</div>
</div>
<!-- End Breadcrumb -->
</div>
<!-- Sidebar -->
<div id="hs-application-sidebar" class="hs-overlay [--auto-close:lg]
hs-overlay-open:translate-x-0
-translate-x-full transition-all duration-300 transform
w-[260px] h-full
hidden
fixed inset-y-0 start-0 z-[60]
bg-white border-e border-gray-200
lg:block lg:translate-x-0 lg:end-auto lg:bottom-0
dark:bg-neutral-800 dark:border-neutral-700" role="dialog" tabindex="-1" aria-label="Sidebar">
<div class="relative flex flex-col h-full max-h-full">
<div class="px-6 pt-4">
<!-- Logo -->
<a
class="stillboxLogo flex-none rounded-xl text-xl inline-block font-semibold focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
<!-- Content -->
<div
class="h-full overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500"
>
<nav
class="hs-accordion-group p-3 w-full flex flex-col flex-wrap"
data-hs-accordion-always-open
>
<ul class="flex flex-col space-y-1">
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/home"
routerLinkActive="btn-active"
>
<ng-icon name="ionHome"></ng-icon>Home
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/talkgroups"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionChatbubbles"></ng-icon>Talkgroups
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/calls"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionMegaphoneOutline"></ng-icon>Calls
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-900 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/incidents"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionNewspaperOutline"></ng-icon>Incidents
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-900 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/alerts"
routerLinkActive="btn-active"
>
<ng-icon name="ionAlertCircleOutline"></ng-icon>Alerts
</a>
</li>
</ul> </ul>
</nav> </nav>
<div class="flex overflow-hidden relative"> </div>
<!-- Sidebar --> <!-- End Content -->
<aside class="h-screen sticky top-0 flex flex-col overflow-y-auto gap-2 py-6 px-2 bg-base-200"> </div>
<a class="btn btn-square btn-ghost btn-secondary text-xl" title="Home" routerLink="/home" routerLinkActive="btn-active"> </div>
<ng-icon name="ionHome"></ng-icon> }
</a> <!-- End Sidebar -->
<a routerLink="/calls" routerLinkActive="btn-active" class="btn btn-ghost btn-secondary text-xl" title="Calls"> <!-- Content -->
<ng-icon name="ionMegaphoneOutline"></ng-icon> @if (auth.loggedIn) {
</a> <div class="w-full lg:ps-64">
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<a class="btn btn-square btn-ghost text-xl" title="Talkgroups" routerLink="/talkgroups" routerLinkActive="btn-active">
<ng-icon name="ionChatbubbles"></ng-icon>
</a>
<div class="divider my-0"></div>
<a class="btn btn-square btn-ghost text-xl" title="Incidents" routerLink="/incidents" routerLinkActive="btn-active">
<ng-icon name="ionNewspaperOutline"></ng-icon>
</a>
<a class="btn btn-square btn-ghost text-xl" title="Alerts" routerLink="/alerts" routerLinkActive="btn-active">
<ng-icon name="ionAlertCircleOutline"></ng-icon>
</a>
<div class="divider my-0"></div>
<a class="btn btn-circle btn-ghost text-xl" title="Listen">
<ng-icon name="ionRadioOutline"></ng-icon>
</a>
</aside>
<div class="container mx-auto px-4">
<router-outlet /> <router-outlet />
</div> </div>
</div> </div>
} @else {
<div class="w-full">
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<router-outlet />
</div>
</div>
}

View file

@ -3,17 +3,47 @@ import { RouterModule, RouterOutlet, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AuthService } from './login/auth.service'; import { AuthService } from './login/auth.service';
import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ionMenuOutline, ionChatbubbles, ionNewspaperOutline, ionAlertCircleOutline, ionRadioOutline, ionHome, ionMegaphoneOutline } from '@ng-icons/ionicons'; import {
ionMenuOutline,
ionChatbubbles,
ionNewspaperOutline,
ionAlertCircleOutline,
ionRadioOutline,
ionHome,
ionMegaphoneOutline,
ionCreateOutline,
} from '@ng-icons/ionicons';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, RouterModule, RouterLink, NgIconComponent], imports: [
CommonModule,
RouterOutlet,
RouterModule,
RouterLink,
NgIconComponent,
],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.css', styleUrl: './app.component.css',
providers: [ provideIcons({ ionMenuOutline, ionChatbubbles, ionNewspaperOutline, ionAlertCircleOutline, ionRadioOutline, ionHome, ionMegaphoneOutline })], providers: [
provideIcons({
ionMenuOutline,
ionChatbubbles,
ionNewspaperOutline,
ionAlertCircleOutline,
ionRadioOutline,
ionHome,
ionMegaphoneOutline,
ionCreateOutline,
}),
],
}) })
export class AppComponent { export class AppComponent {
auth: AuthService = inject(AuthService); auth: AuthService = inject(AuthService);
title = 'admin'; title = 'admin';
logout() {
this.auth.logout();
}
} }

View file

@ -7,7 +7,8 @@ import {
withInterceptors, withInterceptors,
} from '@angular/common/http'; } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isDevMode } from '@angular/core'; import { isDevMode, inject } from '@angular/core';
import { AuthService } from './login/auth.service';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
@ -26,10 +27,28 @@ export function apiBaseInterceptor(
return next(apiReq); return next(apiReq);
} }
export function authIntercept(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
let authSvc: AuthService = inject(AuthService);
if (authSvc.loggedIn) {
req = req.clone({
setHeaders: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
Authorization: `Bearer ${authSvc.getToken()}`,
},
});
}
return next(req);
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withInterceptors([apiBaseInterceptor])), provideHttpClient(withInterceptors([apiBaseInterceptor, authIntercept])),
], ],
}; };

View file

@ -8,9 +8,8 @@ describe('CallsComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CallsComponent] imports: [CallsComponent],
}) }).compileComponents();
.compileComponents();
fixture = TestBed.createComponent(CallsComponent); fixture = TestBed.createComponent(CallsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View file

@ -5,8 +5,6 @@ import { Component } from '@angular/core';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './calls.component.html', templateUrl: './calls.component.html',
styleUrl: './calls.component.css' styleUrl: './calls.component.css',
}) })
export class CallsComponent { export class CallsComponent {}
}

View file

@ -1,3 +1 @@
<p> <p>This will be a dashboard someday.</p>
This will be a dashboard someday.
</p>

View file

@ -7,5 +7,4 @@ import { Component } from '@angular/core';
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrl: './home.component.css', styleUrl: './home.component.css',
}) })
export class HomeComponent { export class HomeComponent {}
}

View file

@ -8,9 +8,8 @@ describe('IncidentsComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IncidentsComponent] imports: [IncidentsComponent],
}) }).compileComponents();
.compileComponents();
fixture = TestBed.createComponent(IncidentsComponent); fixture = TestBed.createComponent(IncidentsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View file

@ -5,8 +5,6 @@ import { Component } from '@angular/core';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './incidents.component.html', templateUrl: './incidents.component.html',
styleUrl: './incidents.component.css' styleUrl: './incidents.component.css',
}) })
export class IncidentsComponent { export class IncidentsComponent {}
}

View file

@ -27,7 +27,7 @@ export class AuthService {
login(username: string, password: string): Observable<HttpResponse<Jwt>> { login(username: string, password: string): Observable<HttpResponse<Jwt>> {
return this.http return this.http
.post<Jwt>( .post<Jwt>(
'/login', '/api/login',
{ username: username, password: password }, { username: username, password: password },
{ observe: 'response' }, { observe: 'response' },
) )
@ -41,4 +41,14 @@ export class AuthService {
}), }),
); );
} }
getToken(): string | null {
return sessionStorage.getItem('jwt');
}
logout() {
sessionStorage.removeItem('jwt');
this.loggedIn = false;
this._router.navigateByUrl('/login');
}
} }

View file

@ -0,0 +1,30 @@
export interface TGID {
sys: number;
tg: number;
}
export interface AlertRule {
times: string[];
mult: number;
}
export interface System {
id: number;
name: string;
}
export interface Talkgroup {
id: number;
system_id: number;
tgid: number;
name: string;
alpha_tag: string;
tg_group: string;
frequency: number;
metadata: Object;
tags: string[];
alert: boolean;
alert_config: Map<TGID, AlertRule[]>;
system: System;
weight: number;
}

View file

@ -0,0 +1 @@
<p>TG comp</p>

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TalkgroupRecordComponent } from './talkgroup-record.component';
describe('TalkgroupRecordComponent', () => {
let component: TalkgroupRecordComponent;
let fixture: ComponentFixture<TalkgroupRecordComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TalkgroupRecordComponent],
}).compileComponents();
fixture = TestBed.createComponent(TalkgroupRecordComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { Talkgroup } from '../../talkgroup';
@Component({
selector: 'talkgroup-record',
standalone: true,
imports: [],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.css',
})
export class TalkgroupRecordComponent {
tg: Talkgroup;
constructor(tg: Talkgroup) {
this.tg = tg;
}
}

View file

@ -1 +1,26 @@
<p>talkgroups works!</p> <div class="w-100 justify-center overflow-x-auto">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>Sys</th>
<th>Sys ID</th>
<th>Name</th>
<th>TG ID</th>
<th></th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
@for (tg of tgs; track tg.id) {
<tr>
<td>{{ tg.system.name }}</td>
<td>{{ tg.system.id }}</td>
<td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td>
<td><ng-icon name="ionCreateOutline"></ng-icon></td>
</tr>
}
</tbody>
</table>
</div>

View file

@ -8,9 +8,8 @@ describe('TalkgroupsComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [TalkgroupsComponent] imports: [TalkgroupsComponent],
}) }).compileComponents();
.compileComponents();
fixture = TestBed.createComponent(TalkgroupsComponent); fixture = TestBed.createComponent(TalkgroupsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View file

@ -1,12 +1,26 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { TalkgroupService } from './talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ionCreateOutline } from '@ng-icons/ionicons';
@Component({ @Component({
selector: 'app-talkgroups', selector: 'talkgroups',
standalone: true, standalone: true,
imports: [], imports: [NgIconComponent],
templateUrl: './talkgroups.component.html', templateUrl: './talkgroups.component.html',
styleUrl: './talkgroups.component.css' styleUrl: './talkgroups.component.css',
providers: [provideIcons({ ionCreateOutline })],
}) })
export class TalkgroupsComponent { export class TalkgroupsComponent {
tgs: Talkgroup[] = [];
tgService: TalkgroupService = inject(TalkgroupService);
ngOnInit() {
this.getTalkgroups();
}
getTalkgroups() {
this.tgService.getTalkgroups().subscribe((tgs) => (this.tgs = tgs));
}
} }

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { APIService } from './api.service';
describe('APIService', () => {
let service: APIService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(APIService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Talkgroup } from '../talkgroup';
@Injectable({
providedIn: 'root',
})
export class TalkgroupService {
loggedIn: boolean = false;
constructor(private http: HttpClient) {}
getTalkgroups(): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/');
}
}

Binary file not shown.

View file

@ -2,3 +2,8 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@font-face {
font-family: "Warnes";
src: url("./assets/Warnes.ttf");
}

View file

@ -57,7 +57,7 @@ func main() {
loginForm.Add("username", *username) loginForm.Add("username", *username)
loginForm.Add("password", *password) loginForm.Add("password", *password)
loginReq, err := http.NewRequest("POST", "http"+secureSuffix()+"://"+*addr+"/login", strings.NewReader(loginForm.Encode())) loginReq, err := http.NewRequest("POST", "http"+secureSuffix()+"://"+*addr+"/api/login", strings.NewReader(loginForm.Encode()))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -87,7 +87,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
u := url.URL{Scheme: "ws" + secureSuffix(), Host: *addr, Path: "/ws"} u := url.URL{Scheme: "ws" + secureSuffix(), Host: *addr, Path: "/api/ws"}
log.Printf("connecting to %s", u.String()) log.Printf("connecting to %s", u.String())
dialer := websocket.Dialer{ dialer := websocket.Dialer{

View file

@ -9,8 +9,8 @@ import (
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/version" "dynatron.me/x/stillbox/internal/version"
"dynatron.me/x/stillbox/pkg/cmd/serve"
"dynatron.me/x/stillbox/pkg/cmd/admin" "dynatron.me/x/stillbox/pkg/cmd/admin"
"dynatron.me/x/stillbox/pkg/cmd/serve"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"

View file

@ -33,5 +33,11 @@ alerting:
renotify: 30m renotify: 30m
notify: notify:
- provider: slackwebhook - provider: slackwebhook
# subjectTemplate: "Stillbox Alert ({{ highest . }})"
# bodyTemplate: |
# {{ range . -}}
# {{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
#
# {{ end -}}
config: config:
webhookURL: "http://somewhere" webhookURL: "http://somewhere"

1
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/go-chi/httprate v0.9.0 github.com/go-chi/httprate v0.9.0
github.com/go-chi/jwtauth/v5 v5.3.1 github.com/go-chi/jwtauth/v5 v5.3.1
github.com/go-chi/render v1.0.3 github.com/go-chi/render v1.0.3
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/golang-migrate/migrate/v4 v4.17.1 github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3

2
go.sum
View file

@ -44,6 +44,8 @@ github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80= github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=

View file

@ -1,9 +1,6 @@
package common package common
import ( import (
"fmt"
"strconv"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -47,10 +44,3 @@ func PtrOrNull[T comparable](val T) *T {
return &val return &val
} }
func FmtFloat(v float64, places ...int) string {
if len(places) > 0 {
return fmt.Sprintf("%."+strconv.Itoa(places[0])+"f", v)
}
return fmt.Sprintf("%.4f", v)
}

View file

@ -0,0 +1,48 @@
package common
import (
"errors"
"fmt"
"strconv"
"text/template"
"time"
"dynatron.me/x/stillbox/internal/jsontime"
)
var (
FuncMap = template.FuncMap{
"f": fmtFloat,
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"formTime": func(t jsontime.Time) string {
return time.Time(t).Format("2006-01-02T15:04")
},
"ago": func(s string) (string, error) {
d, err := time.ParseDuration(s)
if err != nil {
return "", err
}
return time.Now().Add(-d).Format("2006-01-02T15:04"), nil
},
}
)
func fmtFloat(v float64, places ...int) string {
if len(places) > 0 {
return fmt.Sprintf("%."+strconv.Itoa(places[0])+"f", v)
}
return fmt.Sprintf("%.4f", v)
}

View file

@ -0,0 +1,81 @@
package alert
import (
"context"
"fmt"
"strconv"
"time"
"dynatron.me/x/stillbox/internal/trending"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Alert struct {
ID uuid.UUID
Timestamp time.Time
TGName string
Score trending.Score[talkgroups.ID]
OrigScore float64
Weight float32
Suppressed bool
}
func (a *Alert) ToAddAlertParams() database.AddAlertParams {
f32score := float32(a.Score.Score)
f32origscore := float32(a.OrigScore)
var origScore *float32
if a.Score.Score != a.OrigScore {
origScore = &f32origscore
}
return database.AddAlertParams{
ID: a.ID,
Time: pgtype.Timestamptz{Time: a.Timestamp, Valid: true},
PackedTg: a.Score.ID.Pack(),
Weight: &a.Weight,
Score: &f32score,
OrigScore: origScore,
Notified: !a.Suppressed,
}
}
// makeAlert creates a notification for later rendering by the template.
// It takes a talkgroup Score as input.
func Make(ctx context.Context, store talkgroups.Store, score trending.Score[talkgroups.ID], origScore float64) (Alert, error) {
d := Alert{
ID: uuid.New(),
Score: score,
Timestamp: time.Now(),
Weight: 1.0,
OrigScore: origScore,
}
tgRecord, err := store.TG(ctx, score.ID)
switch err {
case nil:
d.Weight = tgRecord.Talkgroup.Weight
if tgRecord.System.Name == "" {
tgRecord.System.Name = strconv.Itoa(int(score.ID.System))
}
if tgRecord.Talkgroup.Name != nil {
d.TGName = fmt.Sprintf("%s %s (%d)", tgRecord.System.Name, *tgRecord.Talkgroup.Name, score.ID.Talkgroup)
} else {
d.TGName = fmt.Sprintf("%s:%d", tgRecord.System.Name, int(score.ID.Talkgroup))
}
default:
system, has := store.SystemName(ctx, int(score.ID.System))
if has {
d.TGName = fmt.Sprintf("%s:%d", system, int(score.ID.Talkgroup))
} else {
d.TGName = fmt.Sprintf("%d:%d", int(score.ID.System), int(score.ID.Talkgroup))
}
}
return d, nil
}

View file

@ -1,27 +1,24 @@
package alerting package alerting
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
"strconv"
"sync" "sync"
"text/template"
"time" "time"
cl "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/alerting/alert"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sinks"
talkgroups "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/internal/timeseries" "dynatron.me/x/stillbox/internal/timeseries"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -46,14 +43,14 @@ type alerter struct {
sync.RWMutex sync.RWMutex
clock timeseries.Clock clock timeseries.Clock
cfg config.Alerting cfg config.Alerting
scorer trending.Scorer[cl.Talkgroup] scorer trending.Scorer[talkgroups.ID]
scores trending.Scores[cl.Talkgroup] scores trending.Scores[talkgroups.ID]
lastScore time.Time lastScore time.Time
sim *Simulation sim *Simulation
alertCache map[cl.Talkgroup]Alert alertCache map[talkgroups.ID]alert.Alert
renotify time.Duration renotify time.Duration
notifier notify.Notifier notifier notify.Notifier
tgCache cl.TalkgroupCache tgCache talkgroups.Store
} }
type offsetClock time.Duration type offsetClock time.Duration
@ -88,14 +85,14 @@ func WithNotifier(n notify.Notifier) AlertOption {
} }
// New creates a new Alerter using the provided configuration. // New creates a new Alerter using the provided configuration.
func New(cfg config.Alerting, tgCache cl.TalkgroupCache, opts ...AlertOption) Alerter { func New(cfg config.Alerting, tgCache talkgroups.Store, opts ...AlertOption) Alerter {
if !cfg.Enable { if !cfg.Enable {
return &noopAlerter{} return &noopAlerter{}
} }
as := &alerter{ as := &alerter{
cfg: cfg, cfg: cfg,
alertCache: make(map[cl.Talkgroup]Alert), alertCache: make(map[talkgroups.ID]alert.Alert),
clock: timeseries.DefaultClock, clock: timeseries.DefaultClock,
renotify: DefaultRenotify, renotify: DefaultRenotify,
tgCache: tgCache, tgCache: tgCache,
@ -111,12 +108,12 @@ func New(cfg config.Alerting, tgCache cl.TalkgroupCache, opts ...AlertOption) Al
as.scorer = trending.NewScorer( as.scorer = trending.NewScorer(
trending.WithTimeSeries(as.newTimeSeries), trending.WithTimeSeries(as.newTimeSeries),
trending.WithStorageDuration[cl.Talkgroup](time.Hour*24*time.Duration(cfg.LookbackDays)), trending.WithStorageDuration[talkgroups.ID](time.Hour*24*time.Duration(cfg.LookbackDays)),
trending.WithRecentDuration[cl.Talkgroup](time.Duration(cfg.Recent)), trending.WithRecentDuration[talkgroups.ID](time.Duration(cfg.Recent)),
trending.WithHalfLife[cl.Talkgroup](time.Duration(cfg.HalfLife)), trending.WithHalfLife[talkgroups.ID](time.Duration(cfg.HalfLife)),
trending.WithScoreThreshold[cl.Talkgroup](ScoreThreshold), trending.WithScoreThreshold[talkgroups.ID](ScoreThreshold),
trending.WithCountThreshold[cl.Talkgroup](CountThreshold), trending.WithCountThreshold[talkgroups.ID](CountThreshold),
trending.WithClock[cl.Talkgroup](as.clock), trending.WithClock[talkgroups.ID](as.clock),
) )
return as return as
@ -149,36 +146,29 @@ func (as *alerter) Go(ctx context.Context) {
} }
const notificationTemplStr = `{{ range . -}} func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]alert.Alert, error) {
{{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
{{ end -}}`
var notificationTemplate = template.Must(template.New("notification").Funcs(funcMap).Parse(notificationTemplStr))
func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]Alert, error) {
err := as.tgCache.Hint(ctx, as.scoredTGs()) err := as.tgCache.Hint(ctx, as.scoredTGs())
if err != nil { if err != nil {
return nil, fmt.Errorf("prime TG cache: %w", err) return nil, fmt.Errorf("prime TG cache: %w", err)
} }
as.Lock()
defer as.Unlock()
db := database.FromCtx(ctx) db := database.FromCtx(ctx)
var notifications []Alert var notifications []alert.Alert
for _, s := range as.scores { for _, s := range as.scores {
origScore := s.Score origScore := s.Score
tgr, has := as.tgCache.TG(ctx, s.ID) tgr, err := as.tgCache.TG(ctx, s.ID)
if has { if err == nil && !tgr.Talkgroup.Alert {
if !tgr.Alert {
continue continue
} }
s.Score *= float64(tgr.Weight)
}
if s.Score > as.cfg.AlertThreshold || testMode { if s.Score > as.cfg.AlertThreshold || testMode {
if old, inCache := as.alertCache[s.ID]; !inCache || now.Sub(old.Timestamp) > as.renotify { if old, inCache := as.alertCache[s.ID]; !inCache || now.Sub(old.Timestamp) > as.renotify {
s.Score = as.tgCache.ApplyAlertRules(s, now) s.Score *= as.tgCache.Weight(ctx, s.ID, now)
a, err := as.makeAlert(ctx, s, origScore) a, err := alert.Make(ctx, as.tgCache, s, origScore)
if err != nil { if err != nil {
return nil, fmt.Errorf("makeAlert: %w", err) return nil, fmt.Errorf("makeAlert: %w", err)
} }
@ -208,9 +198,7 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]Al
} }
func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) { func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
as.RLock() alerts := make([]alert.Alert, 0, len(as.scores))
defer as.RUnlock()
alerts := make([]Alert, 0, len(as.scores))
ctx := r.Context() ctx := r.Context()
alerts, err := as.eval(ctx, time.Now(), true) alerts, err := as.eval(ctx, time.Now(), true)
@ -220,7 +208,7 @@ func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
err = as.sendNotification(ctx, alerts) err = as.notifier.Send(ctx, alerts)
if err != nil { if err != nil {
log.Error().Err(err).Msg("test notification send") log.Error().Err(err).Msg("test notification send")
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -231,8 +219,8 @@ func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
} }
// scoredTGs gets a list of TGs. // scoredTGs gets a list of TGs.
func (as *alerter) scoredTGs() []cl.Talkgroup { func (as *alerter) scoredTGs() []talkgroups.ID {
tgs := make([]cl.Talkgroup, 0, len(as.scores)) tgs := make([]talkgroups.ID, 0, len(as.scores))
for _, s := range as.scores { for _, s := range as.scores {
tgs = append(tgs, s.ID) tgs = append(tgs, s.ID)
} }
@ -256,101 +244,18 @@ func (as *alerter) notify(ctx context.Context) error {
return nil return nil
} }
as.Lock()
defer as.Unlock()
notifications, err := as.eval(ctx, time.Now(), false) notifications, err := as.eval(ctx, time.Now(), false)
if err != nil { if err != nil {
return err return err
} }
if len(notifications) > 0 { if len(notifications) > 0 {
return as.sendNotification(ctx, notifications) return as.notifier.Send(ctx, notifications)
} }
return nil return nil
} }
type Alert struct {
ID uuid.UUID
Timestamp time.Time
TGName string
Score trending.Score[cl.Talkgroup]
OrigScore float64
Weight float32
Suppressed bool
}
func (a *Alert) ToAddAlertParams() database.AddAlertParams {
f32score := float32(a.Score.Score)
f32origscore := float32(a.OrigScore)
var origScore *float32
if a.Score.Score != a.OrigScore {
origScore = &f32origscore
}
return database.AddAlertParams{
ID: a.ID,
Time: pgtype.Timestamptz{Time: a.Timestamp, Valid: true},
PackedTg: a.Score.ID.Pack(),
Weight: &a.Weight,
Score: &f32score,
OrigScore: origScore,
Notified: !a.Suppressed,
}
}
// sendNotification renders and sends the notification.
func (as *alerter) sendNotification(ctx context.Context, n []Alert) error {
msgBuffer := new(bytes.Buffer)
err := notificationTemplate.Execute(msgBuffer, n)
if err != nil {
return fmt.Errorf("notification template render: %w", err)
}
log.Debug().Str("msg", msgBuffer.String()).Msg("notifying")
return as.notifier.Send(ctx, NotificationSubject, msgBuffer.String())
}
// makeAlert creates a notification for later rendering by the template.
// It takes a talkgroup Score as input.
func (as *alerter) makeAlert(ctx context.Context, score trending.Score[cl.Talkgroup], origScore float64) (Alert, error) {
d := Alert{
ID: uuid.New(),
Score: score,
Timestamp: time.Now(),
Weight: 1.0,
OrigScore: origScore,
}
tgRecord, has := as.tgCache.TG(ctx, score.ID)
switch has {
case true:
d.Weight = tgRecord.Weight
if tgRecord.SystemName == "" {
tgRecord.SystemName = strconv.Itoa(int(score.ID.System))
}
if tgRecord.Name != nil {
d.TGName = fmt.Sprintf("%s %s (%d)", tgRecord.SystemName, *tgRecord.Name, score.ID.Talkgroup)
} else {
d.TGName = fmt.Sprintf("%s:%d", tgRecord.SystemName, int(score.ID.Talkgroup))
}
case false:
system, has := as.tgCache.SystemName(ctx, int(score.ID.System))
if has {
d.TGName = fmt.Sprintf("%s:%d", system, int(score.ID.Talkgroup))
} else {
d.TGName = fmt.Sprintf("%d:%d", int(score.ID.System), int(score.ID.Talkgroup))
}
}
return d, nil
}
// cleanCache clears the cache of aged-out entries // cleanCache clears the cache of aged-out entries
func (as *alerter) cleanCache() { func (as *alerter) cleanCache() {
if as.notifier == nil { if as.notifier == nil {
@ -369,7 +274,7 @@ func (as *alerter) cleanCache() {
} }
} }
func (as *alerter) newTimeSeries(id cl.Talkgroup) trending.TimeSeries { func (as *alerter) newTimeSeries(id talkgroups.ID) trending.TimeSeries {
ts, _ := timeseries.NewTimeSeries(timeseries.WithGranularities( ts, _ := timeseries.NewTimeSeries(timeseries.WithGranularities(
[]timeseries.Granularity{ []timeseries.Granularity{
{Granularity: time.Second, Count: 60}, {Granularity: time.Second, Count: 60},
@ -417,7 +322,7 @@ func (as *alerter) backfill(ctx context.Context, since time.Time, until time.Tim
defer as.Unlock() defer as.Unlock()
for rows.Next() { for rows.Next() {
var tg cl.Talkgroup var tg talkgroups.ID
var callDate time.Time var callDate time.Time
if err := rows.Scan(&tg.System, &tg.Talkgroup, &callDate); err != nil { if err := rows.Scan(&tg.System, &tg.Talkgroup, &callDate); err != nil {
return count, err return count, err
@ -440,7 +345,7 @@ func (as *alerter) SinkType() string {
return "alerting" return "alerting"
} }
func (as *alerter) Call(ctx context.Context, call *cl.Call) error { func (as *alerter) Call(ctx context.Context, call *calls.Call) error {
as.Lock() as.Lock()
defer as.Unlock() defer as.Unlock()
as.scorer.AddEvent(call.TalkgroupTuple(), call.DateTime) as.scorer.AddEvent(call.TalkgroupTuple(), call.DateTime)
@ -454,6 +359,6 @@ func (*alerter) Enabled() bool { return true }
type noopAlerter struct{} type noopAlerter struct{}
func (*noopAlerter) SinkType() string { return "noopAlerter" } func (*noopAlerter) SinkType() string { return "noopAlerter" }
func (*noopAlerter) Call(_ context.Context, _ *cl.Call) error { return nil } func (*noopAlerter) Call(_ context.Context, _ *calls.Call) error { return nil }
func (*noopAlerter) Go(_ context.Context) {} func (*noopAlerter) Go(_ context.Context) {}
func (*noopAlerter) Enabled() bool { return false } func (*noopAlerter) Enabled() bool { return false }

View file

@ -12,8 +12,8 @@ import (
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontime" "dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
cl "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -58,9 +58,9 @@ func (s *Simulation) stepClock(t time.Time) {
} }
// Simulate begins the simulation using the DB handle from ctx. It returns final scores. // Simulate begins the simulation using the DB handle from ctx. It returns final scores.
func (s *Simulation) Simulate(ctx context.Context) (trending.Scores[cl.Talkgroup], error) { func (s *Simulation) Simulate(ctx context.Context) (trending.Scores[talkgroups.ID], error) {
now := time.Now() now := time.Now()
tgc := cl.NewTalkgroupCache() tgc := talkgroups.NewCache()
s.Enable = true s.Enable = true
s.alerter = New(s.Alerting, tgc, WithClock(&s.clock)).(*alerter) s.alerter = New(s.Alerting, tgc, WithClock(&s.clock)).(*alerter)

View file

@ -2,17 +2,15 @@ package alerting
import ( import (
_ "embed" _ "embed"
"errors"
"html/template" "html/template"
"net/http" "net/http"
"time" "time"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -22,41 +20,14 @@ import (
//go:embed stats.html //go:embed stats.html
var statsTemplateFile string var statsTemplateFile string
var (
statTmpl = template.Must(template.New("stats").Funcs(common.FuncMap).Parse(statsTemplateFile))
)
type stats interface { type stats interface {
PrivateRoutes(chi.Router) PrivateRoutes(chi.Router)
} }
var (
funcMap = template.FuncMap{
"f": common.FmtFloat,
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"formTime": func(t jsontime.Time) string {
return time.Time(t).Format("2006-01-02T15:04")
},
"ago": func(s string) (string, error) {
d, err := time.ParseDuration(s)
if err != nil {
return "", err
}
return time.Now().Add(-d).Format("2006-01-02T15:04"), nil
},
}
statTmpl = template.Must(template.New("stats").Funcs(funcMap).Parse(statsTemplateFile))
)
func (as *alerter) PrivateRoutes(r chi.Router) { func (as *alerter) PrivateRoutes(r chi.Router) {
r.Get("/tgstats", as.tgStatsHandler) r.Get("/tgstats", as.tgStatsHandler)
r.Post("/tgstats", as.simulateHandler) r.Post("/tgstats", as.simulateHandler)
@ -69,21 +40,21 @@ func (as *alerter) tgStatsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
db := database.FromCtx(ctx) db := database.FromCtx(ctx)
tgs, err := db.GetTalkgroupsByPackedIDs(ctx, as.packedScoredTGs()) tgs, err := db.GetTalkgroupsWithLearnedByPackedIDs(ctx, as.packedScoredTGs())
if err != nil { if err != nil {
log.Error().Err(err).Msg("stats TG get failed") log.Error().Err(err).Msg("stats TG get failed")
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
tgMap := make(map[calls.Talkgroup]database.GetTalkgroupsByPackedIDsRow, len(tgs)) tgMap := make(map[talkgroups.ID]database.GetTalkgroupsWithLearnedByPackedIDsRow, len(tgs))
for _, t := range tgs { for _, t := range tgs {
tgMap[calls.Talkgroup{System: uint32(t.SystemID), Talkgroup: uint32(t.ID)}] = t tgMap[talkgroups.ID{System: uint32(t.System.ID), Talkgroup: uint32(t.Talkgroup.Tgid)}] = t
} }
renderData := struct { renderData := struct {
TGs map[calls.Talkgroup]database.GetTalkgroupsByPackedIDsRow TGs map[talkgroups.ID]database.GetTalkgroupsWithLearnedByPackedIDsRow
Scores trending.Scores[calls.Talkgroup] Scores trending.Scores[talkgroups.ID]
LastScore time.Time LastScore time.Time
Simulation *Simulation Simulation *Simulation
Config config.Alerting Config config.Alerting

View file

@ -85,8 +85,8 @@
{{ range .Scores }} {{ range .Scores }}
{{ $tg := (index $.TGs .ID) }} {{ $tg := (index $.TGs .ID) }}
<tr> <tr>
<td>{{ $tg.Name_2}}</td> <td>{{ $tg.System.Name}}</td>
<td>{{ $tg.Name}}</td> <td>{{ $tg.Talkgroup.Name}}</td>
<td>{{ .ID.Talkgroup }}</td> <td>{{ .ID.Talkgroup }}</td>
<td>{{ f .Count 0 }}</td> <td>{{ f .Count 0 }}</td>
<td>{{ f .RecentCount 0 }}</td> <td>{{ f .RecentCount 0 }}</td>

127
pkg/api/api.go Normal file
View file

@ -0,0 +1,127 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/go-chi/chi/v5"
"github.com/go-viper/mapstructure/v2"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log"
)
type API interface {
Subrouter() http.Handler
}
type api struct {
tgs talkgroups.Store
}
func New(tgs talkgroups.Store) API {
s := &api{
tgs: tgs,
}
return s
}
func (a *api) Subrouter() http.Handler {
r := chi.NewMux()
r.Get("/talkgroup/{system:\\d+}/{id:\\d+}", a.talkgroup)
r.Get("/talkgroup/{system:\\d+}/", a.talkgroup)
r.Get("/talkgroup/", a.talkgroup)
return r
}
var statusMapping = map[error]int{
talkgroups.ErrNotFound: http.StatusNotFound,
pgx.ErrNoRows: http.StatusNotFound,
}
func httpCode(err error) int {
c, ok := statusMapping[err]
if ok {
return c
}
for e, c := range statusMapping { // check if err wraps an error we know about
if errors.Is(err, e) {
return c
}
}
return http.StatusInternalServerError
}
func (a *api) writeResponse(w http.ResponseWriter, r *http.Request, data interface{}, err error) {
if err != nil {
log.Error().Str("path", r.URL.Path).Err(err).Msg("request failed")
http.Error(w, err.Error(), httpCode(err))
return
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
err = enc.Encode(data)
if err != nil {
log.Error().Str("path", r.URL.Path).Err(err).Msg("response marshal failed")
http.Error(w, err.Error(), httpCode(err))
return
}
}
func decodeParams(d interface{}, r *http.Request) error {
params := chi.RouteContext(r.Context()).URLParams
m := make(map[string]string, len(params.Keys))
for i, k := range params.Keys {
m[k] = params.Values[i]
}
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Metadata: nil,
Result: d,
TagName: "param",
WeaklyTypedInput: true,
})
if err != nil {
return err
}
return dec.Decode(m)
}
func (a *api) badReq(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
}
func (a *api) talkgroup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := struct {
System *int `param:"system"`
ID *int `param:"id"`
}{}
err := decodeParams(&p, r)
if err != nil {
a.badReq(w, err)
return
}
var res interface{}
switch {
case p.System != nil && p.ID != nil:
res, err = a.tgs.TG(ctx, talkgroups.TG(*p.System, *p.ID))
case p.System != nil:
res, err = a.tgs.SystemTGs(ctx, int32(*p.System))
default:
res, err = a.tgs.TGs(ctx, nil)
}
a.writeResponse(w, r, res, err)
}

View file

@ -4,7 +4,10 @@ import (
"errors" "errors"
"net/http" "net/http"
_ "embed"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
) )
@ -66,3 +69,20 @@ func ErrorResponse(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
func (a *Auth) PublicRoutes(r chi.Router) {
r.Post("/api/login", a.routeAuth)
r.Get("/api/login", a.routeLogin)
}
func (a *Auth) PrivateRoutes(r chi.Router) {
r.Get("/refresh", a.routeRefresh)
}
//go:embed login.html
var loginPage []byte
func (a *Auth) routeLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(loginPage)
}

View file

@ -110,14 +110,6 @@ func (a *Auth) newToken(uid int32) string {
return tokenString return tokenString
} }
func (a *Auth) PublicRoutes(r chi.Router) {
r.Post("/login", a.routeAuth)
}
func (a *Auth) PrivateRoutes(r chi.Router) {
r.Get("/refresh", a.routeRefresh)
}
func (a *Auth) allowInsecureCookie(r *http.Request) bool { func (a *Auth) allowInsecureCookie(r *http.Request) bool {
host := strings.Split(r.Host, ":") host := strings.Split(r.Host, ":")
v, has := a.cfg.AllowInsecure[host[0]] v, has := a.cfg.AllowInsecure[host[0]]

17
pkg/auth/login.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<div>
<form action="/login" method="POST">
<label for="username">Username: </label>
<input type="text" name="username" />
<label for="password">Password: </label>
<input type="password" name="password" />
<input type="submit" value="Login" />
</form>
</div>
</body>
</html>

View file

@ -7,7 +7,9 @@ import (
"dynatron.me/x/stillbox/internal/audio" "dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/google/uuid"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@ -17,7 +19,7 @@ func (d CallDuration) Duration() time.Duration {
return time.Duration(d) return time.Duration(d)
} }
func (d CallDuration) Int32Ptr() *int32 { func (d CallDuration) MsInt32Ptr() *int32 {
if time.Duration(d) == 0 { if time.Duration(d) == 0 {
return nil return nil
} }
@ -31,6 +33,7 @@ func (d CallDuration) Seconds() int32 {
} }
type Call struct { type Call struct {
ID uuid.UUID
Audio []byte Audio []byte
AudioName string AudioName string
AudioType string AudioType string
@ -67,6 +70,7 @@ func Make(call *Call, dontStore bool) (*Call, error) {
} }
call.shouldStore = dontStore call.shouldStore = dontStore
call.ID = uuid.New()
return call, nil return call, nil
} }
@ -91,6 +95,7 @@ func toInt32Slice(s []int) []int32 {
func (c *Call) ToPB() *pb.Call { func (c *Call) ToPB() *pb.Call {
return &pb.Call{ return &pb.Call{
Id: c.ID.String(),
AudioName: c.AudioName, AudioName: c.AudioName,
AudioType: c.AudioType, AudioType: c.AudioType,
DateTime: timestamppb.New(c.DateTime), DateTime: timestamppb.New(c.DateTime),
@ -101,7 +106,7 @@ func (c *Call) ToPB() *pb.Call {
Frequencies: toInt64Slice(c.Frequencies), Frequencies: toInt64Slice(c.Frequencies),
Patches: toInt32Slice(c.Patches), Patches: toInt32Slice(c.Patches),
Sources: toInt32Slice(c.Sources), Sources: toInt32Slice(c.Sources),
Duration: c.Duration.Int32Ptr(), Duration: c.Duration.MsInt32Ptr(),
Audio: c.Audio, Audio: c.Audio,
} }
} }
@ -128,3 +133,7 @@ func (c *Call) computeLength() (err error) {
return nil return nil
} }
func (c *Call) TalkgroupTuple() talkgroups.ID {
return talkgroups.TG(c.System, c.Talkgroup)
}

View file

@ -5,16 +5,18 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
tgs "dynatron.me/x/stillbox/pkg/talkgroups"
) )
type TalkgroupFilter struct { type TalkgroupFilter struct {
Talkgroups []Talkgroup `json:"talkgroups,omitempty"` Talkgroups []tgs.ID `json:"talkgroups,omitempty"`
TalkgroupsNot []Talkgroup `json:"talkgroupsNot,omitempty"` TalkgroupsNot []tgs.ID `json:"talkgroupsNot,omitempty"`
TalkgroupTagsAll []string `json:"talkgroupTagsAll,omitempty"` TalkgroupTagsAll []string `json:"talkgroupTagsAll,omitempty"`
TalkgroupTagsAny []string `json:"talkgroupTagsAny,omitempty"` TalkgroupTagsAny []string `json:"talkgroupTagsAny,omitempty"`
TalkgroupTagsNot []string `json:"talkgroupTagsNot,omitempty"` TalkgroupTagsNot []string `json:"talkgroupTagsNot,omitempty"`
talkgroups map[Talkgroup]bool talkgroups map[tgs.ID]bool
} }
func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter, error) { func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter, error) {
@ -25,9 +27,9 @@ func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter,
} }
if l := len(p.Talkgroups); l > 0 { if l := len(p.Talkgroups); l > 0 {
tgf.Talkgroups = make([]Talkgroup, l) tgf.Talkgroups = make([]tgs.ID, l)
for i, t := range p.Talkgroups { for i, t := range p.Talkgroups {
tgf.Talkgroups[i] = Talkgroup{ tgf.Talkgroups[i] = tgs.ID{
System: uint32(t.System), System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup), Talkgroup: uint32(t.Talkgroup),
} }
@ -35,9 +37,9 @@ func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter,
} }
if l := len(p.TalkgroupsNot); l > 0 { if l := len(p.TalkgroupsNot); l > 0 {
tgf.TalkgroupsNot = make([]Talkgroup, l) tgf.TalkgroupsNot = make([]tgs.ID, l)
for i, t := range p.TalkgroupsNot { for i, t := range p.TalkgroupsNot {
tgf.TalkgroupsNot[i] = Talkgroup{ tgf.TalkgroupsNot[i] = tgs.ID{
System: uint32(t.System), System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup), Talkgroup: uint32(t.Talkgroup),
} }
@ -51,12 +53,12 @@ func (f *TalkgroupFilter) hasTags() bool {
return len(f.TalkgroupTagsAny) > 0 || len(f.TalkgroupTagsAll) > 0 || len(f.TalkgroupTagsNot) > 0 return len(f.TalkgroupTagsAny) > 0 || len(f.TalkgroupTagsAll) > 0 || len(f.TalkgroupTagsNot) > 0
} }
func (f *TalkgroupFilter) GetFinalTalkgroups() map[Talkgroup]bool { func (f *TalkgroupFilter) GetFinalTalkgroups() map[tgs.ID]bool {
return f.talkgroups return f.talkgroups
} }
func (f *TalkgroupFilter) compile(ctx context.Context) error { func (f *TalkgroupFilter) compile(ctx context.Context) error {
f.talkgroups = make(map[Talkgroup]bool) f.talkgroups = make(map[tgs.ID]bool)
for _, tg := range f.Talkgroups { for _, tg := range f.Talkgroups {
f.talkgroups[tg] = true f.talkgroups[tg] = true
} }
@ -69,7 +71,7 @@ func (f *TalkgroupFilter) compile(ctx context.Context) error {
} }
for _, tg := range tagTGs { for _, tg := range tagTGs {
f.talkgroups[Talkgroup{System: uint32(tg.SystemID), Talkgroup: uint32(tg.Tgid)}] = true f.talkgroups[tgs.ID{System: uint32(tg.SystemID), Talkgroup: uint32(tg.Tgid)}] = true
} }
} }

View file

@ -1,191 +0,0 @@
package calls
import (
"context"
"fmt"
"sync"
"time"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/internal/ruletime"
"dynatron.me/x/stillbox/internal/trending"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log"
)
type Talkgroup struct {
System uint32
Talkgroup uint32
}
func (c *Call) TalkgroupTuple() Talkgroup {
return Talkgroup{System: uint32(c.System), Talkgroup: uint32(c.Talkgroup)}
}
func TG[T int | uint | int64 | uint64 | int32 | uint32](sys, tgid T) Talkgroup {
return Talkgroup{
System: uint32(sys),
Talkgroup: uint32(tgid),
}
}
func (t Talkgroup) Pack() int64 {
// P25 system IDs are 12 bits, so we can fit them in a signed 8 byte int (int64, pg INT8)
return int64((int64(t.System) << 32) | int64(t.Talkgroup))
}
func (t Talkgroup) String() string {
return fmt.Sprintf("%d:%d", t.System, t.Talkgroup)
}
func PackedTGs(tg []Talkgroup) []int64 {
s := make([]int64, len(tg))
for i, v := range tg {
s[i] = v.Pack()
}
return s
}
type tgMap map[Talkgroup]database.GetTalkgroupWithLearnedByPackedIDsRow
type TalkgroupCache interface {
TG(ctx context.Context, tg Talkgroup) (database.GetTalkgroupWithLearnedByPackedIDsRow, bool)
SystemName(ctx context.Context, id int) (string, bool)
ApplyAlertRules(score trending.Score[Talkgroup], t time.Time, coversOpts ...ruletime.CoversOption) float64
Hint(ctx context.Context, tgs []Talkgroup) error
Load(ctx context.Context, tgs []int64) error
Invalidate()
}
func (t *talkgroupCache) Invalidate() {
t.Lock()
defer t.Unlock()
clear(t.tgs)
clear(t.systems)
clear(t.AlertConfig)
}
type talkgroupCache struct {
sync.RWMutex
AlertConfig
tgs tgMap
systems map[int32]string
}
func NewTalkgroupCache() TalkgroupCache {
tgc := &talkgroupCache{
tgs: make(tgMap),
systems: make(map[int32]string),
AlertConfig: make(AlertConfig),
}
return tgc
}
func (t *talkgroupCache) Hint(ctx context.Context, tgs []Talkgroup) error {
t.RLock()
var toLoad []int64
if len(t.tgs) > len(tgs)/2 { // TODO: instrument this
for _, tg := range tgs {
_, ok := t.tgs[tg]
if !ok {
toLoad = append(toLoad, tg.Pack())
}
}
} else {
toLoad = make([]int64, 0, len(tgs))
for _, g := range tgs {
toLoad = append(toLoad, g.Pack())
}
}
if len(toLoad) > 0 {
t.RUnlock()
return t.Load(ctx, toLoad)
}
t.RUnlock()
return nil
}
func (t *talkgroupCache) add(rec database.GetTalkgroupWithLearnedByPackedIDsRow) error {
tg := TG(rec.SystemID, rec.Tgid)
t.tgs[tg] = rec
t.systems[rec.SystemID] = rec.SystemName
return t.AlertConfig.AddAlertConfig(tg, rec.AlertConfig)
}
func (t *talkgroupCache) Load(ctx context.Context, tgs []int64) error {
tgRecords, err := database.FromCtx(ctx).GetTalkgroupWithLearnedByPackedIDs(ctx, tgs)
if err != nil {
return err
}
t.Lock()
defer t.Unlock()
for _, rec := range tgRecords {
err := t.add(rec)
if err != nil {
log.Error().Err(err).Msg("add alert config fail")
}
}
return nil
}
func (t *talkgroupCache) TG(ctx context.Context, tg Talkgroup) (database.GetTalkgroupWithLearnedByPackedIDsRow, bool) {
t.RLock()
rec, has := t.tgs[tg]
t.RUnlock()
if has {
return rec, has
}
recs, err := database.FromCtx(ctx).GetTalkgroupWithLearnedByPackedIDs(ctx, []int64{tg.Pack()})
switch err {
case nil:
case pgx.ErrNoRows:
return database.GetTalkgroupWithLearnedByPackedIDsRow{}, false
default:
log.Error().Err(err).Msg("TG() cache add db get")
return database.GetTalkgroupWithLearnedByPackedIDsRow{}, false
}
if len(recs) < 1 {
return database.GetTalkgroupWithLearnedByPackedIDsRow{}, false
}
t.Lock()
defer t.Unlock()
err = t.add(recs[0])
if err != nil {
log.Error().Err(err).Msg("TG() cache add")
return recs[0], false
}
return recs[0], true
}
func (t *talkgroupCache) SystemName(ctx context.Context, id int) (name string, has bool) {
n, has := t.systems[int32(id)]
if !has {
sys, err := database.FromCtx(ctx).GetSystemName(ctx, id)
if err != nil {
return "", false
}
return sys, true
}
return n, has
}

View file

@ -65,14 +65,19 @@ type Alerting struct {
type Notify []NotifyService type Notify []NotifyService
type NotifyService struct { type NotifyService struct {
Provider string `json:"provider"` Provider string `yaml:"provider" json:"provider"`
Config map[string]interface{} `json:"config"` SubjectTemplate *string `yaml:"subjectTemplate" json:"subjectTemplate"`
BodyTemplate *string `yaml:"bodyTemplate" json:"bodyTemplate"`
Config map[string]interface{} `yaml:"config" json:"config"`
} }
func (n *NotifyService) GetS(k, defaultVal string) string { func (n *NotifyService) GetS(k, defaultVal string) string {
if v, has := n.Config[k].(string); has { if v, has := n.Config[k]; has {
if v, isString := v.(string); isString {
return v return v
} }
log.Error().Str("configKey", k).Str("provider", n.Provider).Str("default", defaultVal).Msg("notify config value is not a string! using default")
}
return defaultVal return defaultVal
} }

View file

@ -52,7 +52,7 @@ func (q *Queries) AddAlert(ctx context.Context, arg AddAlertParams) error {
return err return err
} }
const addCall = `-- name: AddCall :one const addCall = `-- name: AddCall :exec
INSERT INTO calls ( INSERT INTO calls (
id, id,
submitter, submitter,
@ -71,11 +71,29 @@ INSERT INTO calls (
tg_alpha_tag, tg_alpha_tag,
tg_group, tg_group,
source source
) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES (
RETURNING id $1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13,
$14,
$15,
$16,
$17
)
` `
type AddCallParams struct { type AddCallParams struct {
ID uuid.UUID `json:"id"`
Submitter *int32 `json:"submitter"` Submitter *int32 `json:"submitter"`
System int `json:"system"` System int `json:"system"`
Talkgroup int `json:"talkgroup"` Talkgroup int `json:"talkgroup"`
@ -94,8 +112,9 @@ type AddCallParams struct {
Source int `json:"source"` Source int `json:"source"`
} }
func (q *Queries) AddCall(ctx context.Context, arg AddCallParams) (uuid.UUID, error) { func (q *Queries) AddCall(ctx context.Context, arg AddCallParams) error {
row := q.db.QueryRow(ctx, addCall, _, err := q.db.Exec(ctx, addCall,
arg.ID,
arg.Submitter, arg.Submitter,
arg.System, arg.System,
arg.Talkgroup, arg.Talkgroup,
@ -113,9 +132,7 @@ func (q *Queries) AddCall(ctx context.Context, arg AddCallParams) (uuid.UUID, er
arg.TgGroup, arg.TgGroup,
arg.Source, arg.Source,
) )
var id uuid.UUID return err
err := row.Scan(&id)
return id, err
} }
const getDatabaseSize = `-- name: GetDatabaseSize :one const getDatabaseSize = `-- name: GetDatabaseSize :one

11
pkg/database/extend.go Normal file
View file

@ -0,0 +1,11 @@
package database
func (d GetTalkgroupsWithLearnedByPackedIDsRow) GetTalkgroup() Talkgroup { return d.Talkgroup }
func (d GetTalkgroupsWithLearnedByPackedIDsRow) GetSystem() System { return d.System }
func (d GetTalkgroupsWithLearnedByPackedIDsRow) GetLearned() bool { return d.Learned }
func (g GetTalkgroupsWithLearnedRow) GetTalkgroup() Talkgroup { return g.Talkgroup }
func (g GetTalkgroupsWithLearnedRow) GetSystem() System { return g.System }
func (g GetTalkgroupsWithLearnedRow) GetLearned() bool { return g.Learned }
func (g GetTalkgroupsWithLearnedBySystemRow) GetTalkgroup() Talkgroup { return g.Talkgroup }
func (g GetTalkgroupsWithLearnedBySystemRow) GetSystem() System { return g.System }
func (g GetTalkgroupsWithLearnedBySystemRow) GetLearned() bool { return g.Learned }

View file

@ -13,7 +13,7 @@ import (
type Querier interface { type Querier interface {
AddAlert(ctx context.Context, arg AddAlertParams) error AddAlert(ctx context.Context, arg AddAlertParams) error
AddCall(ctx context.Context, arg AddCallParams) (uuid.UUID, error) AddCall(ctx context.Context, arg AddCallParams) error
BulkSetTalkgroupTags(ctx context.Context, iD int64, tags []string) error BulkSetTalkgroupTags(ctx context.Context, iD int64, tags []string) error
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error) CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
@ -22,14 +22,16 @@ type Querier interface {
GetAPIKey(ctx context.Context, apiKey string) (ApiKey, error) GetAPIKey(ctx context.Context, apiKey string) (ApiKey, error)
GetDatabaseSize(ctx context.Context) (string, error) GetDatabaseSize(ctx context.Context) (string, error)
GetSystemName(ctx context.Context, systemID int) (string, error) GetSystemName(ctx context.Context, systemID int) (string, error)
GetTalkgroup(ctx context.Context, systemID int, tgid int) (Talkgroup, error) GetTalkgroup(ctx context.Context, systemID int, tgid int) (GetTalkgroupRow, error)
GetTalkgroupIDsByTags(ctx context.Context, anytags []string, alltags []string, nottags []string) ([]GetTalkgroupIDsByTagsRow, error) GetTalkgroupIDsByTags(ctx context.Context, anytags []string, alltags []string, nottags []string) ([]GetTalkgroupIDsByTagsRow, error)
GetTalkgroupTags(ctx context.Context, sys int, tg int) ([]string, error) GetTalkgroupTags(ctx context.Context, sys int, tg int) ([]string, error)
GetTalkgroupWithLearned(ctx context.Context, systemID int, tgid int) (GetTalkgroupWithLearnedRow, error) GetTalkgroupWithLearned(ctx context.Context, systemID int, tgid int) (GetTalkgroupWithLearnedRow, error)
GetTalkgroupWithLearnedByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupWithLearnedByPackedIDsRow, error)
GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsByPackedIDsRow, error) GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsByPackedIDsRow, error)
GetTalkgroupsWithAllTags(ctx context.Context, tags []string) ([]Talkgroup, error) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAllTagsRow, error)
GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) ([]Talkgroup, error) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAnyTagsRow, error)
GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroupsWithLearnedRow, error)
GetTalkgroupsWithLearnedByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsWithLearnedByPackedIDsRow, error)
GetTalkgroupsWithLearnedBySystem(ctx context.Context, system int32) ([]GetTalkgroupsWithLearnedBySystemRow, error)
GetUserByID(ctx context.Context, id int32) (User, error) GetUserByID(ctx context.Context, id int32) (User, error)
GetUserByUID(ctx context.Context, id int32) (User, error) GetUserByUID(ctx context.Context, id int32) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error)

View file

@ -31,26 +31,30 @@ func (q *Queries) GetSystemName(ctx context.Context, systemID int) (string, erro
} }
const getTalkgroup = `-- name: GetTalkgroup :one const getTalkgroup = `-- name: GetTalkgroup :one
SELECT id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight FROM talkgroups SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
WHERE id = systg2id($1, $2) WHERE id = systg2id($1, $2)
` `
func (q *Queries) GetTalkgroup(ctx context.Context, systemID int, tgid int) (Talkgroup, error) { type GetTalkgroupRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
}
func (q *Queries) GetTalkgroup(ctx context.Context, systemID int, tgid int) (GetTalkgroupRow, error) {
row := q.db.QueryRow(ctx, getTalkgroup, systemID, tgid) row := q.db.QueryRow(ctx, getTalkgroup, systemID, tgid)
var i Talkgroup var i GetTalkgroupRow
err := row.Scan( err := row.Scan(
&i.ID, &i.Talkgroup.ID,
&i.SystemID, &i.Talkgroup.SystemID,
&i.Tgid, &i.Talkgroup.Tgid,
&i.Name, &i.Talkgroup.Name,
&i.AlphaTag, &i.Talkgroup.AlphaTag,
&i.TgGroup, &i.Talkgroup.TgGroup,
&i.Frequency, &i.Talkgroup.Frequency,
&i.Metadata, &i.Talkgroup.Metadata,
&i.Tags, &i.Talkgroup.Tags,
&i.Alert, &i.Talkgroup.Alert,
&i.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Weight, &i.Talkgroup.Weight,
) )
return i, err return i, err
} }
@ -101,19 +105,17 @@ func (q *Queries) GetTalkgroupTags(ctx context.Context, sys int, tg int) ([]stri
const getTalkgroupWithLearned = `-- name: GetTalkgroupWithLearned :one const getTalkgroupWithLearned = `-- name: GetTalkgroupWithLearned :one
SELECT SELECT
tg.id, tg.system_id, sys.name system_name, tg.tgid, tg.name, tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alpha_tag,
tg.alert, tg.weight, tg.alert_config,
FALSE learned FALSE learned
FROM talkgroups tg FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = systg2id($1, $2) WHERE tg.id = systg2id($1, $2)
UNION UNION
SELECT SELECT
tgl.id::INT8, tgl.system_id::INT4, sys.name system_name, tgl.tgid::INT4, tgl.name, tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, NULL::INTEGER, NULL::JSONB, tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END, tgl.alpha_tag, CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, 1.0, NULL::JSONB, TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned TRUE learned
FROM talkgroups_learned tgl FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id JOIN systems sys ON tgl.system_id = sys.id
@ -121,19 +123,8 @@ WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
` `
type GetTalkgroupWithLearnedRow struct { type GetTalkgroupWithLearnedRow struct {
ID int64 `json:"id"` Talkgroup Talkgroup `json:"talkgroup"`
SystemID int32 `json:"system_id"` System System `json:"system"`
SystemName string `json:"system_name"`
Tgid int32 `json:"tgid"`
Name *string `json:"name"`
TgGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata []byte `json:"metadata"`
Tags []string `json:"tags"`
AlphaTag *string `json:"alpha_tag"`
Alert bool `json:"alert"`
Weight float32 `json:"weight"`
AlertConfig []byte `json:"alert_config"`
Learned bool `json:"learned"` Learned bool `json:"learned"`
} }
@ -141,118 +132,34 @@ func (q *Queries) GetTalkgroupWithLearned(ctx context.Context, systemID int, tgi
row := q.db.QueryRow(ctx, getTalkgroupWithLearned, systemID, tgid) row := q.db.QueryRow(ctx, getTalkgroupWithLearned, systemID, tgid)
var i GetTalkgroupWithLearnedRow var i GetTalkgroupWithLearnedRow
err := row.Scan( err := row.Scan(
&i.ID, &i.Talkgroup.ID,
&i.SystemID, &i.Talkgroup.SystemID,
&i.SystemName, &i.Talkgroup.Tgid,
&i.Tgid, &i.Talkgroup.Name,
&i.Name, &i.Talkgroup.AlphaTag,
&i.TgGroup, &i.Talkgroup.TgGroup,
&i.Frequency, &i.Talkgroup.Frequency,
&i.Metadata, &i.Talkgroup.Metadata,
&i.Tags, &i.Talkgroup.Tags,
&i.AlphaTag, &i.Talkgroup.Alert,
&i.Alert, &i.Talkgroup.AlertConfig,
&i.Weight, &i.Talkgroup.Weight,
&i.AlertConfig, &i.System.ID,
&i.System.Name,
&i.Learned, &i.Learned,
) )
return i, err return i, err
} }
const getTalkgroupWithLearnedByPackedIDs = `-- name: GetTalkgroupWithLearnedByPackedIDs :many
SELECT
tg.id, tg.system_id, sys.name system_name, tg.tgid, tg.name,
tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alpha_tag,
tg.alert, tg.weight, tg.alert_config,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[])
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, sys.name system_name, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END, tgl.alpha_tag,
TRUE, 1.0, NULL::JSONB,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE systg2id(tgl.system_id, tgl.tgid) = ANY($1::INT8[]) AND ignored IS NOT TRUE
`
type GetTalkgroupWithLearnedByPackedIDsRow struct {
ID int64 `json:"id"`
SystemID int32 `json:"system_id"`
SystemName string `json:"system_name"`
Tgid int32 `json:"tgid"`
Name *string `json:"name"`
TgGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata []byte `json:"metadata"`
Tags []string `json:"tags"`
AlphaTag *string `json:"alpha_tag"`
Alert bool `json:"alert"`
Weight float32 `json:"weight"`
AlertConfig []byte `json:"alert_config"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupWithLearnedByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupWithLearnedByPackedIDsRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupWithLearnedByPackedIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTalkgroupWithLearnedByPackedIDsRow
for rows.Next() {
var i GetTalkgroupWithLearnedByPackedIDsRow
if err := rows.Scan(
&i.ID,
&i.SystemID,
&i.SystemName,
&i.Tgid,
&i.Name,
&i.TgGroup,
&i.Frequency,
&i.Metadata,
&i.Tags,
&i.AlphaTag,
&i.Alert,
&i.Weight,
&i.AlertConfig,
&i.Learned,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTalkgroupsByPackedIDs = `-- name: GetTalkgroupsByPackedIDs :many const getTalkgroupsByPackedIDs = `-- name: GetTalkgroupsByPackedIDs :many
SELECT tg.id, system_id, tgid, tg.name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, sys.id, sys.name FROM talkgroups tg SELECT tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[]) WHERE tg.id = ANY($1::INT8[])
` `
type GetTalkgroupsByPackedIDsRow struct { type GetTalkgroupsByPackedIDsRow struct {
ID int64 `json:"id"` Talkgroup Talkgroup `json:"talkgroup"`
SystemID int32 `json:"system_id"` System System `json:"system"`
Tgid int32 `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TgGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata []byte `json:"metadata"`
Tags []string `json:"tags"`
Alert bool `json:"alert"`
AlertConfig []byte `json:"alert_config"`
Weight float32 `json:"weight"`
ID_2 int `json:"id_2"`
Name_2 string `json:"name_2"`
} }
func (q *Queries) GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsByPackedIDsRow, error) { func (q *Queries) GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsByPackedIDsRow, error) {
@ -265,20 +172,20 @@ func (q *Queries) GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64
for rows.Next() { for rows.Next() {
var i GetTalkgroupsByPackedIDsRow var i GetTalkgroupsByPackedIDsRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.Talkgroup.ID,
&i.SystemID, &i.Talkgroup.SystemID,
&i.Tgid, &i.Talkgroup.Tgid,
&i.Name, &i.Talkgroup.Name,
&i.AlphaTag, &i.Talkgroup.AlphaTag,
&i.TgGroup, &i.Talkgroup.TgGroup,
&i.Frequency, &i.Talkgroup.Frequency,
&i.Metadata, &i.Talkgroup.Metadata,
&i.Tags, &i.Talkgroup.Tags,
&i.Alert, &i.Talkgroup.Alert,
&i.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Weight, &i.Talkgroup.Weight,
&i.ID_2, &i.System.ID,
&i.Name_2, &i.System.Name,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -291,32 +198,36 @@ func (q *Queries) GetTalkgroupsByPackedIDs(ctx context.Context, dollar_1 []int64
} }
const getTalkgroupsWithAllTags = `-- name: GetTalkgroupsWithAllTags :many const getTalkgroupsWithAllTags = `-- name: GetTalkgroupsWithAllTags :many
SELECT id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight FROM talkgroups SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
WHERE tags && ARRAY[$1] WHERE tags && ARRAY[$1]
` `
func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) ([]Talkgroup, error) { type GetTalkgroupsWithAllTagsRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
}
func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAllTagsRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsWithAllTags, tags) rows, err := q.db.Query(ctx, getTalkgroupsWithAllTags, tags)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Talkgroup var items []GetTalkgroupsWithAllTagsRow
for rows.Next() { for rows.Next() {
var i Talkgroup var i GetTalkgroupsWithAllTagsRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.Talkgroup.ID,
&i.SystemID, &i.Talkgroup.SystemID,
&i.Tgid, &i.Talkgroup.Tgid,
&i.Name, &i.Talkgroup.Name,
&i.AlphaTag, &i.Talkgroup.AlphaTag,
&i.TgGroup, &i.Talkgroup.TgGroup,
&i.Frequency, &i.Talkgroup.Frequency,
&i.Metadata, &i.Talkgroup.Metadata,
&i.Tags, &i.Talkgroup.Tags,
&i.Alert, &i.Talkgroup.Alert,
&i.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Weight, &i.Talkgroup.Weight,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -329,32 +240,218 @@ func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) (
} }
const getTalkgroupsWithAnyTags = `-- name: GetTalkgroupsWithAnyTags :many const getTalkgroupsWithAnyTags = `-- name: GetTalkgroupsWithAnyTags :many
SELECT id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight FROM talkgroups SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
WHERE tags @> ARRAY[$1] WHERE tags @> ARRAY[$1]
` `
func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) ([]Talkgroup, error) { type GetTalkgroupsWithAnyTagsRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
}
func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAnyTagsRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsWithAnyTags, tags) rows, err := q.db.Query(ctx, getTalkgroupsWithAnyTags, tags)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Talkgroup var items []GetTalkgroupsWithAnyTagsRow
for rows.Next() { for rows.Next() {
var i Talkgroup var i GetTalkgroupsWithAnyTagsRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.Talkgroup.ID,
&i.SystemID, &i.Talkgroup.SystemID,
&i.Tgid, &i.Talkgroup.Tgid,
&i.Name, &i.Talkgroup.Name,
&i.AlphaTag, &i.Talkgroup.AlphaTag,
&i.TgGroup, &i.Talkgroup.TgGroup,
&i.Frequency, &i.Talkgroup.Frequency,
&i.Metadata, &i.Talkgroup.Metadata,
&i.Tags, &i.Talkgroup.Tags,
&i.Alert, &i.Talkgroup.Alert,
&i.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Weight, &i.Talkgroup.Weight,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTalkgroupsWithLearned = `-- name: GetTalkgroupsWithLearned :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE
`
type GetTalkgroupsWithLearnedRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroupsWithLearnedRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsWithLearned)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTalkgroupsWithLearnedRow
for rows.Next() {
var i GetTalkgroupsWithLearnedRow
if err := rows.Scan(
&i.Talkgroup.ID,
&i.Talkgroup.SystemID,
&i.Talkgroup.Tgid,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.System.ID,
&i.System.Name,
&i.Learned,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTalkgroupsWithLearnedByPackedIDs = `-- name: GetTalkgroupsWithLearnedByPackedIDs :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[])
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE systg2id(tgl.system_id, tgl.tgid) = ANY($1::INT8[]) AND ignored IS NOT TRUE
`
type GetTalkgroupsWithLearnedByPackedIDsRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearnedByPackedIDs(ctx context.Context, dollar_1 []int64) ([]GetTalkgroupsWithLearnedByPackedIDsRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsWithLearnedByPackedIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTalkgroupsWithLearnedByPackedIDsRow
for rows.Next() {
var i GetTalkgroupsWithLearnedByPackedIDsRow
if err := rows.Scan(
&i.Talkgroup.ID,
&i.Talkgroup.SystemID,
&i.Talkgroup.Tgid,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.System.ID,
&i.System.Name,
&i.Learned,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTalkgroupsWithLearnedBySystem = `-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = $1
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND ignored IS NOT TRUE
`
type GetTalkgroupsWithLearnedBySystemRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearnedBySystem(ctx context.Context, system int32) ([]GetTalkgroupsWithLearnedBySystemRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsWithLearnedBySystem, system)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTalkgroupsWithLearnedBySystemRow
for rows.Next() {
var i GetTalkgroupsWithLearnedBySystemRow
if err := rows.Scan(
&i.Talkgroup.ID,
&i.Talkgroup.SystemID,
&i.Talkgroup.Tgid,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.System.ID,
&i.System.Name,
&i.Learned,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View file

@ -0,0 +1,88 @@
package database
import (
"testing"
"github.com/stretchr/testify/require"
)
const getTalkgroupWithLearnedByPackedIDsTest = `-- name: GetTalkgroupWithLearnedByPackedIDs :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[])
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE systg2id(tgl.system_id, tgl.tgid) = ANY($1::INT8[]) AND ignored IS NOT TRUE
`
const getTalkgroupWithLearnedTest = `-- name: GetTalkgroupWithLearned :one
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = systg2id($1, $2)
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
`
const getTalkgroupsWithLearnedBySystemTest = `-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = $1
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tg.system_id = $1 AND ignored IS NOT TRUE
`
const getTalkgroupsWithLearnedTest = `-- name: GetTalkgroupsWithLearned :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE
`
func TestQueryColumnsMatch(t *testing.T) {
require.Equal(t, getTalkgroupsWithLearnedByPackedIDsTest, getTalkgroupWithLearnedByPackedIDs)
require.Equal(t, getTalkgroupWithLearnedTest, getTalkgroupWithLearned)
require.Equal(t, getTalkgroupsWithLearnedBySystemTest, getTalkgroupsWithLearnedBySystem)
require.Equal(t, getTalkgroupsWithLearnedTest, getTalkgroupsWithLearned)
}

View file

@ -5,10 +5,9 @@ import (
"encoding/json" "encoding/json"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
) )
@ -61,19 +60,18 @@ func (c *client) SendError(cmd *pb.Command, err error) {
} }
func (c *client) Talkgroup(ctx context.Context, tg *pb.Talkgroup) error { func (c *client) Talkgroup(ctx context.Context, tg *pb.Talkgroup) error {
db := database.FromCtx(ctx) tgi, err := talkgroups.StoreFrom(ctx).TG(ctx, talkgroups.TG(tg.System, tg.Talkgroup))
tgi, err := db.GetTalkgroupWithLearned(ctx, int(tg.System), int(tg.Talkgroup))
if err != nil { if err != nil {
if err != pgx.ErrNoRows { if err != talkgroups.ErrNotFound {
log.Error().Err(err).Int32("sys", tg.System).Int32("tg", tg.Talkgroup).Msg("get talkgroup fail") log.Error().Err(err).Int32("sys", tg.System).Int32("tg", tg.Talkgroup).Msg("get talkgroup fail")
} }
return err return err
} }
var md *structpb.Struct var md *structpb.Struct
if len(tgi.Metadata) > 0 { if len(tgi.Talkgroup.Metadata) > 0 {
m := make(map[string]interface{}) m := make(map[string]interface{})
err := json.Unmarshal(tgi.Metadata, &m) err := json.Unmarshal(tgi.Talkgroup.Metadata, &m)
if err != nil { if err != nil {
log.Error().Err(err).Int32("sys", tg.System).Int32("tg", tg.Talkgroup).Msg("unmarshal tg metadata") log.Error().Err(err).Int32("sys", tg.System).Int32("tg", tg.Talkgroup).Msg("unmarshal tg metadata")
} }
@ -85,14 +83,14 @@ func (c *client) Talkgroup(ctx context.Context, tg *pb.Talkgroup) error {
resp := &pb.TalkgroupInfo{ resp := &pb.TalkgroupInfo{
Tg: tg, Tg: tg,
Name: tgi.Name, Name: tgi.Talkgroup.Name,
Group: tgi.TgGroup, Group: tgi.Talkgroup.TgGroup,
Frequency: tgi.Frequency, Frequency: tgi.Talkgroup.Frequency,
Metadata: md, Metadata: md,
Tags: tgi.Tags, Tags: tgi.Talkgroup.Tags,
Learned: tgi.Learned, Learned: tgi.Learned,
AlphaTag: tgi.AlphaTag, AlphaTag: tgi.Talkgroup.AlphaTag,
SystemName: tgi.SystemName, SystemName: tgi.System.Name,
} }
_ = c.Send(&pb.Message{ _ = c.Send(&pb.Message{

View file

@ -183,5 +183,5 @@ func (conn *wsConn) writeToClient(w io.WriteCloser, msg ToClient) {
} }
func (n *wsManager) PrivateRoutes(r chi.Router) { func (n *wsManager) PrivateRoutes(r chi.Router) {
r.HandleFunc("/ws", n.serveWS) r.HandleFunc("/api/ws", n.serveWS)
} }

View file

@ -1,28 +1,87 @@
package notify package notify
import ( import (
"bytes"
"context"
"fmt" "fmt"
stdhttp "net/http" stdhttp "net/http"
"text/template"
"time" "time"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/alerting/alert"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"github.com/go-viper/mapstructure/v2"
"github.com/nikoksr/notify" "github.com/nikoksr/notify"
"github.com/nikoksr/notify/service/http" "github.com/nikoksr/notify/service/http"
) )
type Notifier interface { type Notifier interface {
notify.Notifier Send(ctx context.Context, alerts []alert.Alert) error
}
type backend struct {
*notify.Notify
subject *template.Template
body *template.Template
} }
type notifier struct { type notifier struct {
*notify.Notify backends []backend
} }
func (n *notifier) buildSlackWebhookPayload(cfg config.NotifyService) func(string, string) any { func highest(a []alert.Alert) string {
icon := cfg.GetS("icon", "🚨") if len(a) < 1 {
url := cfg.GetS("messageURL", "") return "none"
}
top := a[0]
for _, a := range a {
if a.Score.Score > top.Score.Score {
top = a
}
}
return top.TGName
}
var alertFm = template.FuncMap{
"highest": highest,
}
const defaultBodyTemplStr = `{{ range . -}}
{{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
{{ end -}}`
var defaultBodyTemplate = template.Must(template.New("body").Funcs(common.FuncMap).Funcs(alertFm).Parse(defaultBodyTemplStr))
var defaultSubjectTemplStr = `Stillbox Alert ({{ highest . }}`
var defaultSubjectTemplate = template.Must(template.New("subject").Funcs(common.FuncMap).Funcs(alertFm).Parse(defaultSubjectTemplStr))
// Send renders and sends the Alerts.
func (b *backend) Send(ctx context.Context, alerts []alert.Alert) (err error) {
var subject, body bytes.Buffer
err = b.subject.ExecuteTemplate(&subject, "subject", alerts)
if err != nil {
return err
}
err = b.body.ExecuteTemplate(&body, "body", alerts)
if err != nil {
return err
}
err = b.Notify.Send(ctx, subject.String(), body.String())
if err != nil {
return err
}
return nil
}
func buildSlackWebhookPayload(cfg *slackWebhookConfig) func(string, string) any {
type Attachment struct { type Attachment struct {
Title string `json:"title"` Title string `json:"title"`
Text string `json:"text"` Text string `json:"text"`
@ -42,40 +101,90 @@ func (n *notifier) buildSlackWebhookPayload(cfg config.NotifyService) func(strin
{ {
Title: subject, Title: subject,
Text: message, Text: message,
TitleLink: url, TitleLink: cfg.MessageURL,
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
}, },
}, },
IconEmoji: icon, IconEmoji: cfg.Icon,
} }
return m return m
} }
} }
func (n *notifier) addService(cfg config.NotifyService) error { type slackWebhookConfig struct {
WebhookURL string `mapstructure:"webhookURL"`
Icon string `mapstructure:"icon"`
MessageURL string `mapstructure:"messageURL"`
SubjectTemplate string `mapstructure:"subjectTemplate"`
BodyTemplate string `mapstructure:"bodyTemplate"`
}
func (n *notifier) addService(cfg config.NotifyService) (err error) {
be := backend{}
switch cfg.SubjectTemplate {
case nil:
be.subject = defaultSubjectTemplate
default:
be.subject, err = template.New("subject").Funcs(common.FuncMap).Funcs(alertFm).Parse(*cfg.SubjectTemplate)
if err != nil {
return err
}
}
switch cfg.BodyTemplate {
case nil:
be.body = defaultBodyTemplate
default:
be.body, err = template.New("body").Funcs(common.FuncMap).Funcs(alertFm).Parse(*cfg.BodyTemplate)
if err != nil {
return err
}
}
be.Notify = notify.New()
switch cfg.Provider { switch cfg.Provider {
case "slackwebhook": case "slackwebhook":
swc := &slackWebhookConfig{
Icon: "🚨",
}
err := mapstructure.Decode(cfg.Config, &swc)
if err != nil {
return err
}
hs := http.New() hs := http.New()
hs.AddReceivers(&http.Webhook{ hs.AddReceivers(&http.Webhook{
ContentType: "application/json", ContentType: "application/json",
Header: make(stdhttp.Header), Header: make(stdhttp.Header),
Method: stdhttp.MethodPost, Method: stdhttp.MethodPost,
URL: cfg.GetS("webhookURL", ""), URL: swc.WebhookURL,
BuildPayload: n.buildSlackWebhookPayload(cfg), BuildPayload: buildSlackWebhookPayload(swc),
}) })
n.UseServices(hs) be.UseServices(hs)
default: default:
return fmt.Errorf("unknown provider '%s'", cfg.Provider) return fmt.Errorf("unknown provider '%s'", cfg.Provider)
} }
n.backends = append(n.backends, be)
return nil
}
func (n *notifier) Send(ctx context.Context, alerts []alert.Alert) error {
for _, be := range n.backends {
err := be.Send(ctx, alerts)
if err != nil {
return err
}
}
return nil return nil
} }
func New(cfg config.Notify) (Notifier, error) { func New(cfg config.Notify) (Notifier, error) {
n := &notifier{ n := new(notifier)
Notify: notify.NewWithServices(),
}
for _, s := range cfg { for _, s := range cfg {
err := n.addService(s) err := n.addService(s)

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.33.0 // protoc-gen-go v1.33.0
// protoc v5.28.2 // protoc v5.28.3
// source: stillbox.proto // source: stillbox.proto
package pb package pb
@ -288,18 +288,19 @@ type Call struct {
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
AudioName string `protobuf:"bytes,1,opt,name=audioName,proto3" json:"audioName,omitempty"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
AudioType string `protobuf:"bytes,2,opt,name=audioType,proto3" json:"audioType,omitempty"` AudioName string `protobuf:"bytes,2,opt,name=audioName,proto3" json:"audioName,omitempty"`
DateTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=date_time,json=dateTime,proto3" json:"date_time,omitempty"` AudioType string `protobuf:"bytes,3,opt,name=audioType,proto3" json:"audioType,omitempty"`
System int32 `protobuf:"varint,4,opt,name=system,proto3" json:"system,omitempty"` DateTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=date_time,json=dateTime,proto3" json:"date_time,omitempty"`
Talkgroup int32 `protobuf:"varint,5,opt,name=talkgroup,proto3" json:"talkgroup,omitempty"` System int32 `protobuf:"varint,5,opt,name=system,proto3" json:"system,omitempty"`
Source int32 `protobuf:"varint,6,opt,name=source,proto3" json:"source,omitempty"` Talkgroup int32 `protobuf:"varint,6,opt,name=talkgroup,proto3" json:"talkgroup,omitempty"`
Frequency int64 `protobuf:"varint,7,opt,name=frequency,proto3" json:"frequency,omitempty"` Source int32 `protobuf:"varint,7,opt,name=source,proto3" json:"source,omitempty"`
Frequencies []int64 `protobuf:"varint,8,rep,packed,name=frequencies,proto3" json:"frequencies,omitempty"` Frequency int64 `protobuf:"varint,8,opt,name=frequency,proto3" json:"frequency,omitempty"`
Patches []int32 `protobuf:"varint,9,rep,packed,name=patches,proto3" json:"patches,omitempty"` Frequencies []int64 `protobuf:"varint,9,rep,packed,name=frequencies,proto3" json:"frequencies,omitempty"`
Sources []int32 `protobuf:"varint,10,rep,packed,name=sources,proto3" json:"sources,omitempty"` Patches []int32 `protobuf:"varint,10,rep,packed,name=patches,proto3" json:"patches,omitempty"`
Duration *int32 `protobuf:"varint,11,opt,name=duration,proto3,oneof" json:"duration,omitempty"` Sources []int32 `protobuf:"varint,11,rep,packed,name=sources,proto3" json:"sources,omitempty"`
Audio []byte `protobuf:"bytes,12,opt,name=audio,proto3" json:"audio,omitempty"` Duration *int32 `protobuf:"varint,12,opt,name=duration,proto3,oneof" json:"duration,omitempty"`
Audio []byte `protobuf:"bytes,13,opt,name=audio,proto3" json:"audio,omitempty"`
} }
func (x *Call) Reset() { func (x *Call) Reset() {
@ -334,6 +335,13 @@ func (*Call) Descriptor() ([]byte, []int) {
return file_stillbox_proto_rawDescGZIP(), []int{2} return file_stillbox_proto_rawDescGZIP(), []int{2}
} }
func (x *Call) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *Call) GetAudioName() string { func (x *Call) GetAudioName() string {
if x != nil { if x != nil {
return x.AudioName return x.AudioName
@ -1187,29 +1195,30 @@ var file_stillbox_proto_rawDesc = []byte{
0x6f, 0x75, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x06, 0x74, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x75, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x06, 0x74, 0x67, 0x49, 0x6e, 0x66,
0x6f, 0x42, 0x12, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x42, 0x12, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x72, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x5f, 0x69, 0x64, 0x22, 0x81, 0x03, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x1c, 0x0a, 0x64, 0x5f, 0x69, 0x64, 0x22, 0x91, 0x03, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x0e, 0x0a,
0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1c, 0x0a,
0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61,
0x75, 0x64, 0x69, 0x6f, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x64, 0x69, 0x6f, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
0x61, 0x75, 0x64, 0x69, 0x6f, 0x54, 0x79, 0x70, 0x65, 0x12, 0x37, 0x0a, 0x09, 0x64, 0x61, 0x74, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x54, 0x79, 0x70, 0x65, 0x12, 0x37, 0x0a, 0x09, 0x64, 0x61, 0x74,
0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69,
0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x05, 0x20, 0x01,
0x28, 0x05, 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x61, 0x28, 0x05, 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x61,
0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74,
0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x07, 0x20, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x08, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x20,
0x0a, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x08, 0x20, 0x0a, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x09, 0x20,
0x03, 0x28, 0x03, 0x52, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73,
0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28,
0x05, 0x52, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x6f, 0x05, 0x52, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x05, 0x52, 0x07, 0x73, 0x6f, 0x75, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x05, 0x52, 0x07, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x72, 0x63, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0c, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x0b, 0x0a, 0x09, 0x5f,
0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3e, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3e, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c,
0x6f, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x6f, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x66, 0x6f,

View file

@ -24,18 +24,19 @@ message CommandResponse {
} }
message Call { message Call {
string audioName = 1; string id = 1;
string audioType = 2; string audioName = 2;
google.protobuf.Timestamp date_time = 3; string audioType = 3;
int32 system = 4; google.protobuf.Timestamp date_time = 4;
int32 talkgroup = 5; int32 system = 5;
int32 source = 6; int32 talkgroup = 6;
int64 frequency = 7; int32 source = 7;
repeated int64 frequencies = 8; int64 frequency = 8;
repeated int32 patches = 9; repeated int64 frequencies = 9;
repeated int32 sources = 10; repeated int32 patches = 10;
optional int32 duration = 11; repeated int32 sources = 11;
bytes audio = 12; optional int32 duration = 12;
bytes audio = 13;
} }
message Hello { message Hello {

View file

@ -36,6 +36,7 @@ func (s *Server) setupRoutes() {
s.nex.PrivateRoutes(r) s.nex.PrivateRoutes(r)
s.auth.PrivateRoutes(r) s.auth.PrivateRoutes(r)
s.alerter.PrivateRoutes(r) s.alerter.PrivateRoutes(r)
r.Mount("/api", s.api.Subrouter())
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {

View file

@ -6,8 +6,8 @@ import (
"os" "os"
"time" "time"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/alerting" "dynatron.me/x/stillbox/pkg/alerting"
"dynatron.me/x/stillbox/pkg/api"
"dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
@ -15,6 +15,7 @@ import (
"dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources" "dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
@ -35,7 +36,8 @@ type Server struct {
alerter alerting.Alerter alerter alerting.Alerter
notifier notify.Notifier notifier notify.Notifier
hup chan os.Signal hup chan os.Signal
tgCache calls.TalkgroupCache tgs talkgroups.Store
api api.API
} }
func New(ctx context.Context, cfg *config.Config) (*Server, error) { func New(ctx context.Context, cfg *config.Config) (*Server, error) {
@ -58,7 +60,8 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
return nil, err return nil, err
} }
tgCache := calls.NewTalkgroupCache() tgCache := talkgroups.NewCache()
api := api.New(tgCache)
srv := &Server{ srv := &Server{
auth: authenticator, auth: authenticator,
@ -69,7 +72,8 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
logger: logger, logger: logger,
alerter: alerting.New(cfg.Alerting, tgCache, alerting.WithNotifier(notifier)), alerter: alerting.New(cfg.Alerting, tgCache, alerting.WithNotifier(notifier)),
notifier: notifier, notifier: notifier,
tgCache: tgCache, tgs: tgCache,
api: api,
} }
srv.sinks.Register("database", sinks.NewDatabaseSink(srv.db), true) srv.sinks.Register("database", sinks.NewDatabaseSink(srv.db), true)
@ -104,6 +108,7 @@ func (s *Server) Go(ctx context.Context) error {
s.installHupHandler() s.installHupHandler()
ctx = database.CtxWithDB(ctx, s.db) ctx = database.CtxWithDB(ctx, s.db)
ctx = talkgroups.CtxWithStore(ctx, s.tgs)
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: s.conf.Listen, Addr: s.conf.Listen,

View file

@ -17,6 +17,7 @@ func (s *Server) huppers() []hupper {
return []hupper{ return []hupper{
s.logger, s.logger,
s.auth, s.auth,
s.tgs,
} }
} }

View file

@ -26,12 +26,12 @@ func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
return nil return nil
} }
dbCall, err := s.db.AddCall(ctx, s.toAddCallParams(call)) err := s.db.AddCall(ctx, s.toAddCallParams(call))
if err != nil { if err != nil {
return fmt.Errorf("add call: %w", err) return fmt.Errorf("add call: %w", err)
} }
log.Debug().Str("id", dbCall.String()).Int("system", call.System).Int("tgid", call.Talkgroup).Msg("stored") log.Debug().Str("id", call.ID.String()).Int("system", call.System).Int("tgid", call.Talkgroup).Msg("stored")
return nil return nil
} }
@ -42,6 +42,7 @@ func (s *DatabaseSink) SinkType() string {
func (s *DatabaseSink) toAddCallParams(call *calls.Call) database.AddCallParams { func (s *DatabaseSink) toAddCallParams(call *calls.Call) database.AddCallParams {
return database.AddCallParams{ return database.AddCallParams{
ID: call.ID,
Submitter: call.Submitter.Int32Ptr(), Submitter: call.Submitter.Int32Ptr(),
System: call.System, System: call.System,
Talkgroup: call.Talkgroup, Talkgroup: call.Talkgroup,
@ -49,7 +50,7 @@ func (s *DatabaseSink) toAddCallParams(call *calls.Call) database.AddCallParams
AudioName: common.PtrOrNull(call.AudioName), AudioName: common.PtrOrNull(call.AudioName),
AudioBlob: call.Audio, AudioBlob: call.Audio,
AudioType: common.PtrOrNull(call.AudioType), AudioType: common.PtrOrNull(call.AudioType),
Duration: call.Duration.Int32Ptr(), Duration: call.Duration.MsInt32Ptr(),
Frequency: call.Frequency, Frequency: call.Frequency,
Frequencies: call.Frequencies, Frequencies: call.Frequencies,
Patches: call.Patches, Patches: call.Patches,

View file

@ -7,8 +7,8 @@ import (
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/calls"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

View file

@ -1,21 +1,40 @@
package calls package talkgroups
import ( import (
"encoding/json" "encoding/json"
"sync"
"time" "time"
"dynatron.me/x/stillbox/internal/ruletime" "dynatron.me/x/stillbox/internal/ruletime"
"dynatron.me/x/stillbox/internal/trending"
) )
type AlertConfig map[Talkgroup][]AlertRule type AlertConfig struct {
sync.RWMutex
m map[ID][]AlertRule
}
type AlertRule struct { type AlertRule struct {
Times []ruletime.RuleTime `json:"times"` Times []ruletime.RuleTime `json:"times"`
ScoreMultiplier float32 `json:"mult"` ScoreMultiplier float32 `json:"mult"`
} }
func (ac AlertConfig) AddAlertConfig(tg Talkgroup, confBytes []byte) error { func NewAlertConfig() AlertConfig {
return AlertConfig{
m: make(map[ID][]AlertRule),
}
}
func (ac *AlertConfig) GetRules(tg ID) []AlertRule {
ac.RLock()
defer ac.RUnlock()
return ac.m[tg]
}
func (ac *AlertConfig) UnmarshalTGRules(tg ID, confBytes []byte) error {
ac.Lock()
defer ac.Unlock()
if len(confBytes) == 0 { if len(confBytes) == 0 {
return nil return nil
} }
@ -26,17 +45,19 @@ func (ac AlertConfig) AddAlertConfig(tg Talkgroup, confBytes []byte) error {
return err return err
} }
ac[tg] = rules ac.m[tg] = rules
return nil return nil
} }
func (ac AlertConfig) ApplyAlertRules(score trending.Score[Talkgroup], t time.Time, coversOpts ...ruletime.CoversOption) float64 { func (ac *AlertConfig) ApplyAlertRules(id ID, t time.Time, coversOpts ...ruletime.CoversOption) float64 {
s, has := ac[score.ID] ac.RLock()
s, has := ac.m[id]
ac.RUnlock()
if !has { if !has {
return score.Score return 1.0
} }
final := score.Score final := 1.0
for _, ar := range s { for _, ar := range s {
if ar.MatchTime(t, coversOpts...) { if ar.MatchTime(t, coversOpts...) {
@ -47,6 +68,13 @@ func (ac AlertConfig) ApplyAlertRules(score trending.Score[Talkgroup], t time.Ti
return final return final
} }
func (ac *AlertConfig) Invalidate() {
ac.Lock()
defer ac.Unlock()
clear(ac.m)
}
func (ar *AlertRule) MatchTime(t time.Time, coversOpts ...ruletime.CoversOption) bool { func (ar *AlertRule) MatchTime(t time.Time, coversOpts ...ruletime.CoversOption) bool {
for _, at := range ar.Times { for _, at := range ar.Times {
if at.Covers(t, coversOpts...) { if at.Covers(t, coversOpts...) {

View file

@ -1,4 +1,4 @@
package calls_test package talkgroups_test
import ( import (
"errors" "errors"
@ -8,26 +8,26 @@ import (
"dynatron.me/x/stillbox/internal/ruletime" "dynatron.me/x/stillbox/internal/ruletime"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestAlertConfig(t *testing.T) { func TestAlertConfig(t *testing.T) {
ac := make(calls.AlertConfig) ac := talkgroups.NewAlertConfig()
parseTests := []struct { parseTests := []struct {
name string name string
tg calls.Talkgroup tg talkgroups.ID
conf string conf string
compare []calls.AlertRule compare []talkgroups.AlertRule
expectErr error expectErr error
}{ }{
{ {
name: "base case", name: "base case",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
conf: `[{"times":["7:00+2h","01:00+1h","16:00+1h","19:00+4h"],"mult":0.2},{"times":["11:00+1h","15:00+30m","16:03+20m"],"mult":2.0}]`, conf: `[{"times":["7:00+2h","01:00+1h","16:00+1h","19:00+4h"],"mult":0.2},{"times":["11:00+1h","15:00+30m","16:03+20m"],"mult":2.0}]`,
compare: []calls.AlertRule{ compare: []talkgroups.AlertRule{
{ {
Times: []ruletime.RuleTime{ Times: []ruletime.RuleTime{
ruletime.Must(ruletime.New("7:00+2h")), ruletime.Must(ruletime.New("7:00+2h")),
@ -49,7 +49,7 @@ func TestAlertConfig(t *testing.T) {
}, },
{ {
name: "bad spec", name: "bad spec",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
conf: `[{"times":["26:00+2h","01:00+1h","19:00+4h"],"mult":0.2},{"times":["11:00+1h","15:00+30m"],"mult":2.0}]`, conf: `[{"times":["26:00+2h","01:00+1h","19:00+4h"],"mult":0.2},{"times":["11:00+1h","15:00+30m"],"mult":2.0}]`,
expectErr: errors.New("'26:00+2h': invalid hours"), expectErr: errors.New("'26:00+2h': invalid hours"),
}, },
@ -57,12 +57,12 @@ func TestAlertConfig(t *testing.T) {
for _, tc := range parseTests { for _, tc := range parseTests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
err := ac.AddAlertConfig(tc.tg, []byte(tc.conf)) err := ac.UnmarshalTGRules(tc.tg, []byte(tc.conf))
if tc.expectErr != nil { if tc.expectErr != nil {
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectErr.Error()) assert.Contains(t, err.Error(), tc.expectErr.Error())
} else { } else {
assert.Equal(t, tc.compare, ac[tc.tg]) assert.Equal(t, tc.compare, ac.GetRules(tc.tg))
} }
}) })
} }
@ -78,42 +78,42 @@ func TestAlertConfig(t *testing.T) {
evalTests := []struct { evalTests := []struct {
name string name string
tg calls.Talkgroup tg talkgroups.ID
t time.Time t time.Time
origScore float64 origScore float64
expectScore float64 expectScore float64
}{ }{
{ {
name: "base eval", name: "base eval",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
t: tMust("1:20"), t: tMust("1:20"),
origScore: 3, origScore: 3,
expectScore: 0.6, expectScore: 0.6,
}, },
{ {
name: "base eval", name: "base eval",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
t: tMust("23:03"), t: tMust("23:03"),
origScore: 3, origScore: 3,
expectScore: 3, expectScore: 3,
}, },
{ {
name: "base eval", name: "base eval",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
t: tMust("8:03"), t: tMust("8:03"),
origScore: 1.0, origScore: 1.0,
expectScore: 0.2, expectScore: 0.2,
}, },
{ {
name: "base eval", name: "base eval",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
t: tMust("15:15"), t: tMust("15:15"),
origScore: 3.0, origScore: 3.0,
expectScore: 6.0, expectScore: 6.0,
}, },
{ {
name: "overlapping eval", name: "overlapping eval",
tg: calls.TG(197, 3), tg: talkgroups.TG(197, 3),
t: tMust("16:10"), t: tMust("16:10"),
origScore: 1.0, origScore: 1.0,
expectScore: 0.4, expectScore: 0.4,
@ -122,11 +122,11 @@ func TestAlertConfig(t *testing.T) {
for _, tc := range evalTests { for _, tc := range evalTests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
cs := trending.Score[calls.Talkgroup]{ cs := trending.Score[talkgroups.ID]{
ID: tc.tg, ID: tc.tg,
Score: tc.origScore, Score: tc.origScore,
} }
assert.Equal(t, tc.expectScore, toFixed(ac.ApplyAlertRules(cs, tc.t), 5)) assert.Equal(t, tc.expectScore, toFixed(cs.Score*ac.ApplyAlertRules(cs.ID, tc.t), 5))
}) })
} }
} }

292
pkg/talkgroups/cache.go Normal file
View file

@ -0,0 +1,292 @@
package talkgroups
import (
"context"
"errors"
"sync"
"time"
"dynatron.me/x/stillbox/internal/ruletime"
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log"
)
type tgMap map[ID]*Talkgroup
type Store interface {
// TG retrieves a Talkgroup from the Store.
TG(ctx context.Context, tg ID) (*Talkgroup, error)
// TGs retrieves many talkgroups from the Store.
TGs(ctx context.Context, tgs IDs) ([]*Talkgroup, error)
// SystemTGs retrieves all Talkgroups associated with a System.
SystemTGs(ctx context.Context, systemID int32) ([]*Talkgroup, error)
// SystemName retrieves a system name from the store. It returns the record and whether one was found.
SystemName(ctx context.Context, id int) (string, bool)
// ApplyAlertRules applies the score's talkgroup alert rules to the call occurring at t and returns the weighted score.
ApplyAlertRules(id ID, t time.Time, coversOpts ...ruletime.CoversOption) float64
// Hint hints the Store that the provided talkgroups will be asked for.
Hint(ctx context.Context, tgs []ID) error
// Load loads the provided packed talkgroup IDs into the Store.
Load(ctx context.Context, tgs []int64) error
// Invalidate invalidates any caching in the Store.
Invalidate()
// Weight returns the final weight of this talkgroup, including its static and rules-derived weight.
Weight(ctx context.Context, id ID, t time.Time) float64
// Hupper
HUP(*config.Config)
}
type CtxStoreKeyT string
const CtxStoreKey CtxStoreKeyT = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, CtxStoreKey, s)
}
func StoreFrom(ctx context.Context) Store {
s, ok := ctx.Value(CtxStoreKey).(Store)
if !ok {
return NewCache()
}
return s
}
func (t *cache) HUP(_ *config.Config) {
t.Invalidate()
}
func (t *cache) Invalidate() {
t.Lock()
defer t.Unlock()
clear(t.tgs)
clear(t.systems)
t.AlertConfig.Invalidate()
}
type cache struct {
sync.RWMutex
AlertConfig
tgs tgMap
systems map[int32]string
}
// NewCache returns a new cache Store.
func NewCache() Store {
tgc := &cache{
tgs: make(tgMap),
systems: make(map[int32]string),
AlertConfig: NewAlertConfig(),
}
return tgc
}
func (t *cache) Hint(ctx context.Context, tgs []ID) error {
t.RLock()
var toLoad []int64
if len(t.tgs) > len(tgs)/2 { // TODO: instrument this
for _, tg := range tgs {
_, ok := t.tgs[tg]
if !ok {
toLoad = append(toLoad, tg.Pack())
}
}
} else {
toLoad = make([]int64, 0, len(tgs))
for _, g := range tgs {
toLoad = append(toLoad, g.Pack())
}
}
if len(toLoad) > 0 {
t.RUnlock()
return t.Load(ctx, toLoad)
}
t.RUnlock()
return nil
}
func (t *cache) add(rec *Talkgroup) error {
t.Lock()
defer t.Unlock()
tg := TG(rec.System.ID, rec.Talkgroup.Tgid)
t.tgs[tg] = rec
t.systems[int32(rec.System.ID)] = rec.System.Name
return t.AlertConfig.UnmarshalTGRules(tg, rec.Talkgroup.AlertConfig)
}
type row interface {
database.GetTalkgroupsWithLearnedByPackedIDsRow | database.GetTalkgroupsWithLearnedRow |
database.GetTalkgroupsWithLearnedBySystemRow
GetTalkgroup() database.Talkgroup
GetSystem() database.System
GetLearned() bool
}
func rowToTalkgroup[T row](r T) *Talkgroup {
return &Talkgroup{
Talkgroup: r.GetTalkgroup(),
System: r.GetSystem(),
Learned: r.GetLearned(),
}
}
func addToRowList[T row](t *cache, r []*Talkgroup, tgRecords []T) ([]*Talkgroup, error) {
for _, rec := range tgRecords {
tg := rowToTalkgroup(rec)
err := t.add(tg)
if err != nil {
return nil, err
}
r = append(r, tg)
}
return r, nil
}
func (t *cache) TGs(ctx context.Context, tgs IDs) ([]*Talkgroup, error) {
r := make([]*Talkgroup, 0, len(tgs))
var err error
if tgs != nil {
toGet := make(IDs, 0, len(tgs))
t.RLock()
for _, id := range tgs {
rec, has := t.tgs[id]
if has {
r = append(r, rec)
} else {
toGet = append(toGet, id)
}
}
t.RUnlock()
tgRecords, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedByPackedIDs(ctx, toGet.Packed())
if err != nil {
return nil, err
}
return addToRowList(t, r, tgRecords)
}
// get all talkgroups
tgRecords, err := database.FromCtx(ctx).GetTalkgroupsWithLearned(ctx)
if err != nil {
return nil, err
}
return addToRowList(t, r, tgRecords)
}
func (t *cache) Load(ctx context.Context, tgs []int64) error {
tgRecords, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedByPackedIDs(ctx, tgs)
if err != nil {
return err
}
for _, rec := range tgRecords {
err := t.add(rowToTalkgroup(rec))
if err != nil {
log.Error().Err(err).Msg("add alert config fail")
}
}
return nil
}
var ErrNotFound = errors.New("talkgroup not found")
func (t *cache) Weight(ctx context.Context, id ID, tm time.Time) float64 {
tg, err := t.TG(ctx, id)
if err != nil {
return 1.0
}
m := float64(tg.Weight)
m *= t.AlertConfig.ApplyAlertRules(id, tm)
return float64(m)
}
func (t *cache) SystemTGs(ctx context.Context, systemID int32) ([]*Talkgroup, error) {
recs, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedBySystem(ctx, systemID)
if err != nil {
return nil, err
}
r := make([]*Talkgroup, 0, len(recs))
return addToRowList(t, r, recs)
}
func (t *cache) TG(ctx context.Context, tg ID) (*Talkgroup, error) {
t.RLock()
rec, has := t.tgs[tg]
t.RUnlock()
if has {
return rec, nil
}
recs, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedByPackedIDs(ctx, []int64{tg.Pack()})
switch err {
case nil:
case pgx.ErrNoRows:
return nil, ErrNotFound
default:
log.Error().Err(err).Msg("TG() cache add db get")
return nil, errors.Join(ErrNotFound, err)
}
if len(recs) < 1 {
return nil, ErrNotFound
}
err = t.add(rowToTalkgroup(recs[0]))
if err != nil {
log.Error().Err(err).Msg("TG() cache add")
return rowToTalkgroup(recs[0]), errors.Join(ErrNotFound, err)
}
return rowToTalkgroup(recs[0]), nil
}
func (t *cache) SystemName(ctx context.Context, id int) (name string, has bool) {
t.RLock()
n, has := t.systems[int32(id)]
t.RUnlock()
if !has {
sys, err := database.FromCtx(ctx).GetSystemName(ctx, id)
if err != nil {
return "", false
}
t.Lock()
t.systems[int32(id)] = sys
t.Unlock()
return sys, true
}
return n, has
}

View file

@ -0,0 +1,55 @@
package talkgroups
import (
"fmt"
"dynatron.me/x/stillbox/pkg/database"
)
type Talkgroup struct {
database.Talkgroup
System database.System `json:"system"`
Learned bool `json:"learned"`
}
type Names struct {
System string
Talkgroup string
}
type ID struct {
System uint32 `json:"sys"`
Talkgroup uint32 `json:"tg"`
}
type IDs []ID
func (ids *IDs) Packed() []int64 {
r := make([]int64, len(*ids))
for i := range *ids {
r[i] = (*ids)[i].Pack()
}
return r
}
type intId interface {
int | uint | int64 | uint64 | int32 | uint32
}
func TG[T intId, U intId](sys T, tgid U) ID {
return ID{
System: uint32(sys),
Talkgroup: uint32(tgid),
}
}
func (t ID) Pack() int64 {
// P25 system IDs are 12 bits, so we can fit them in a signed 8 byte int (int64, pg INT8)
return int64((int64(t.System) << 32) | int64(t.Talkgroup))
}
func (t ID) String() string {
return fmt.Sprintf("%d:%d", t.System, t.Talkgroup)
}

View file

@ -1,4 +1,4 @@
-- name: AddCall :one -- name: AddCall :exec
INSERT INTO calls ( INSERT INTO calls (
id, id,
submitter, submitter,
@ -17,8 +17,25 @@ INSERT INTO calls (
tg_alpha_tag, tg_alpha_tag,
tg_group, tg_group,
source source
) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES (
RETURNING id; @id,
@submitter,
@system,
@talkgroup,
@call_date,
@audio_name,
@audio_blob,
@audio_type,
@audio_url,
@duration,
@frequency,
@frequencies,
@patches,
@tg_label,
@tg_alpha_tag,
@tg_group,
@source
);
-- name: SetCallTranscript :exec -- name: SetCallTranscript :exec
UPDATE calls SET transcript = $2 WHERE id = $1; UPDATE calls SET transcript = $2 WHERE id = $1;

View file

@ -1,9 +1,9 @@
-- name: GetTalkgroupsWithAnyTags :many -- name: GetTalkgroupsWithAnyTags :many
SELECT * FROM talkgroups SELECT sqlc.embed(talkgroups) FROM talkgroups
WHERE tags @> ARRAY[$1]; WHERE tags @> ARRAY[$1];
-- name: GetTalkgroupsWithAllTags :many -- name: GetTalkgroupsWithAllTags :many
SELECT * FROM talkgroups SELECT sqlc.embed(talkgroups) FROM talkgroups
WHERE tags && ARRAY[$1]; WHERE tags && ARRAY[$1];
-- name: GetTalkgroupIDsByTags :many -- name: GetTalkgroupIDsByTags :many
@ -25,53 +25,85 @@ UPDATE talkgroups SET tags = $2
WHERE id = ANY($1); WHERE id = ANY($1);
-- name: GetTalkgroup :one -- name: GetTalkgroup :one
SELECT * FROM talkgroups SELECT sqlc.embed(talkgroups) FROM talkgroups
WHERE id = systg2id(sqlc.arg(system_id), sqlc.arg(tgid)); WHERE id = systg2id(sqlc.arg(system_id), sqlc.arg(tgid));
-- name: GetTalkgroupsByPackedIDs :many -- name: GetTalkgroupsByPackedIDs :many
SELECT * FROM talkgroups tg SELECT sqlc.embed(tg), sqlc.embed(sys) FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[]); WHERE tg.id = ANY($1::INT8[]);
-- name: GetTalkgroupWithLearned :one -- name: GetTalkgroupWithLearned :one
SELECT SELECT
tg.id, tg.system_id, sys.name system_name, tg.tgid, tg.name, sqlc.embed(tg), sqlc.embed(sys),
tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alpha_tag,
tg.alert, tg.weight, tg.alert_config,
FALSE learned FALSE learned
FROM talkgroups tg FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = systg2id(sqlc.arg(system_id), sqlc.arg(tgid)) WHERE tg.id = systg2id(sqlc.arg(system_id), sqlc.arg(tgid))
UNION UNION
SELECT SELECT
tgl.id::INT8, tgl.system_id::INT4, sys.name system_name, tgl.tgid::INT4, tgl.name, tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, NULL::INTEGER, NULL::JSONB, tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END, tgl.alpha_tag, CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, 1.0, NULL::JSONB, TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned TRUE learned
FROM talkgroups_learned tgl FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = sqlc.arg(system_id) AND tgl.tgid = sqlc.arg(tgid) AND ignored IS NOT TRUE; WHERE tgl.system_id = sqlc.arg(system_id) AND tgl.tgid = sqlc.arg(tgid) AND ignored IS NOT TRUE;
-- name: GetTalkgroupWithLearnedByPackedIDs :many -- name: GetTalkgroupsWithLearnedByPackedIDs :many
SELECT SELECT
tg.id, tg.system_id, sys.name system_name, tg.tgid, tg.name, sqlc.embed(tg), sqlc.embed(sys),
tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alpha_tag,
tg.alert, tg.weight, tg.alert_config,
FALSE learned FALSE learned
FROM talkgroups tg FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id JOIN systems sys ON tg.system_id = sys.id
WHERE tg.id = ANY($1::INT8[]) WHERE tg.id = ANY($1::INT8[])
UNION UNION
SELECT SELECT
tgl.id::INT8, tgl.system_id::INT4, sys.name system_name, tgl.tgid::INT4, tgl.name, tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, NULL::INTEGER, NULL::JSONB, tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END, tgl.alpha_tag, CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, 1.0, NULL::JSONB, TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned TRUE learned
FROM talkgroups_learned tgl FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id JOIN systems sys ON tgl.system_id = sys.id
WHERE systg2id(tgl.system_id, tgl.tgid) = ANY($1::INT8[]) AND ignored IS NOT TRUE; WHERE systg2id(tgl.system_id, tgl.tgid) = ANY($1::INT8[]) AND ignored IS NOT TRUE;
-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
sqlc.embed(tg), sqlc.embed(sys),
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = @system
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = @system AND ignored IS NOT TRUE;
-- name: GetTalkgroupsWithLearned :many
SELECT
sqlc.embed(tg), sqlc.embed(sys),
FALSE learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
UNION
SELECT
tgl.id::INT8, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE;
-- name: GetSystemName :one -- name: GetSystemName :one
SELECT name FROM systems WHERE id = sqlc.arg(system_id); SELECT name FROM systems WHERE id = sqlc.arg(system_id);