Compare commits

..

2 commits

Author SHA1 Message Date
54544200bc import works, needs error reporting 2024-11-20 11:43:25 -05:00
f5ffd54bdf Admin import 2024-11-20 11:43:25 -05:00
96 changed files with 16757 additions and 597 deletions

View file

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
client/admin/.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View file

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
client/admin/.prettierrc Normal file
View file

@ -0,0 +1 @@
{}

4
client/admin/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/admin/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
client/admin/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

27
client/admin/README.md Normal file
View file

@ -0,0 +1,27 @@
# Admin
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.10.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

90
client/admin/angular.json Normal file
View file

@ -0,0 +1,90 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"admin": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/admin",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "admin:build:production"
},
"development": {
"buildTarget": "admin:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.css"],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

14635
client/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
client/admin/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "admin",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"@ng-icons/core": "^29.6.1",
"@ng-icons/ionicons": "^29.6.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.10",
"@angular/cli": "^18.2.10",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"daisyui": "^4.12.13",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "~5.5.2"
}
}

View file

@ -0,0 +1 @@
<p>alerts works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-alerts',
standalone: true,
imports: [],
templateUrl: './alerts.component.html',
styleUrl: './alerts.component.css',
})
export class AlertsComponent {}

View file

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

View file

@ -0,0 +1,302 @@
<!-- ========== HEADER ========== -->
@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 -->
<a
class="flex-none rounded-md text-xl inline-block stillboxLogo focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
<div
class="w-full flex items-center justify-end ms-auto md:justify-between gap-x-1 md:gap-x-3"
>
<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="input 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 input-bordered"
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>
</div>
<div
class="absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-3 text-gray-400"
>
<span class="mx-1"> </span>
<span class="text-xs">/</span>
</div>
</div>
<!-- End Search Input -->
</div>
<div class="flex flex-row items-center justify-end gap-1">
<button
type="button"
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>
</nav>
</div>
<!-- End Content -->
</div>
</div>
}
<!-- End Sidebar -->
<!-- Content -->
@if (auth.loggedIn) {
<div class="w-full lg:ps-64">
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<router-outlet />
</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

@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'admin' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('admin');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, admin');
});
});

View file

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

View file

@ -0,0 +1,54 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import {
HttpRequest,
HttpHandlerFn,
HttpEvent,
withInterceptors,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { isDevMode, inject } from '@angular/core';
import { AuthService } from './login/auth.service';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export function apiBaseInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
let baseUrl: string;
if (isDevMode()) {
baseUrl = 'http://xenon:3050';
} else {
baseUrl = '';
}
const apiReq = req.clone({ url: `${baseUrl}${req.url}` });
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 = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([apiBaseInterceptor, authIntercept])),
],
};

View file

@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { LoginComponent } from './login/login.component';
import { TalkgroupsComponent } from './talkgroups/talkgroups.component';
import { TalkgroupRecordComponent } from './talkgroups/talkgroup-record/talkgroup-record.component';
import { CallsComponent } from './calls/calls.component';
import { IncidentsComponent } from './incidents/incidents.component';
import { AlertsComponent } from './alerts/alerts.component';
import { ImportComponent } from './talkgroups/import/import.component';
import { AuthGuard } from './auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
{ path: 'login', component: LoginComponent },
{ path: 'talkgroups', component: TalkgroupsComponent },
{ path: 'talkgroups/import', component: ImportComponent },
{ path: 'talkgroups/:sys/:tg', component: TalkgroupRecordComponent },
{ path: 'calls', component: CallsComponent },
{ path: 'incidents', component: IncidentsComponent },
{ path: 'alerts', component: AlertsComponent },
];

View file

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth.guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View file

@ -0,0 +1,9 @@
import { CanActivateFn } from '@angular/router';
export const AuthGuard: CanActivateFn = (route, state) => {
if (sessionStorage.getItem('jwt') == null) {
return false;
} else {
return true;
}
};

View file

@ -0,0 +1 @@
<p>calls works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-calls',
standalone: true,
imports: [],
templateUrl: './calls.component.html',
styleUrl: './calls.component.css',
})
export class CallsComponent {}

View file

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

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
standalone: true,
imports: [],
templateUrl: './home.component.html',
styleUrl: './home.component.css',
})
export class HomeComponent {}

View file

@ -0,0 +1 @@
<p>incidents works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-incidents',
standalone: true,
imports: [],
templateUrl: './incidents.component.html',
styleUrl: './incidents.component.css',
})
export class IncidentsComponent {}

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,54 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
export class Jwt {
constructor(public jwt: string) {}
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
loggedIn: boolean = false;
constructor(
private http: HttpClient,
private _router: Router,
) {
let ssJWT = sessionStorage.getItem('jwt');
if (ssJWT) {
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
}
login(username: string, password: string): Observable<HttpResponse<Jwt>> {
return this.http
.post<Jwt>(
'/api/login',
{ username: username, password: password },
{ observe: 'response' },
)
.pipe(
tap((event) => {
if (event.status == 200) {
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
}),
);
}
getToken(): string | null {
return sessionStorage.getItem('jwt');
}
logout() {
sessionStorage.removeItem('jwt');
this.loggedIn = false;
this._router.navigateByUrl('/login');
}
}

View file

@ -0,0 +1,36 @@
<div class="flex justify-center">
<div class="card w-96 bg-base-100 shadow-xl mt-20 mb-20">
<div class="card-body">
<div class="items-center mt-2">
<label class="input input-bordered flex items-center gap-2 mb-2">
<input
type="text"
[(ngModel)]="username"
class="grow"
placeholder="login"
(keyup.enter)="onSubmit()"
/>
</label>
<label class="input input-bordered flex items-center gap-2 mb-2">
<input
type="password"
[(ngModel)]="password"
class="grow"
placeholder="password"
(keyup.enter)="onSubmit()"
/>
</label>
</div>
<div class="card-actions justify-end">
<button (click)="onSubmit()" class="btn btn-primary w-full">
Login
</button>
</div>
</div>
@if (failed) {
<div role="alert" class="alert alert-error">
<span>Login Failed!</span>
</div>
}
</div>
</div>

View file

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

View file

@ -0,0 +1,39 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../login/auth.service';
import { catchError, of } from 'rxjs';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
})
export class LoginComponent {
apiService: AuthService = inject(AuthService);
router: Router = inject(Router);
username: string = '';
password: string = '';
failed: boolean = false;
onSubmit() {
this.failed = false;
this.apiService
.login(this.username, this.password)
.pipe(
catchError(() => {
this.failed = true;
return of(null);
}),
)
.subscribe((event) => {
if (event?.status == 200) {
this.router.navigateByUrl('/home');
} else {
this.failed = true;
}
});
}
}

View file

@ -0,0 +1,70 @@
export interface TGID {
sys: number;
tg: number;
}
export class AlertTime {
time: string;
duration: string;
constructor(at: string) {
let s = at.split('+');
this.time = s[0];
this.duration = s[1];
}
}
export class AlertRule {
times: AlertTime[];
mult: number;
constructor(times: AlertTime[], mult: number) {
this.times = times;
this.mult = mult;
}
}
export interface System {
id: number;
name: string;
}
export interface Metadata {
encrypted: boolean|null;
}
export interface Talkgroup {
id: number;
system_id: number;
tgid: number;
name: string;
alpha_tag: string;
tg_group: string;
frequency: number;
metadata: Metadata|null;
tags: string[];
alert: boolean;
system?: System;
alert_config: AlertRule[];
weight: number;
learned?: boolean;
}
export interface TalkgroupUI extends Talkgroup {
selected?: boolean;
};
export interface TalkgroupUpdate {
id: number;
system_id: number;
tgid: number;
name: string | null;
alpha_tag: string | null;
tg_group: string | null;
frequency: number | null;
metadata: Object | null;
tags: string[] | null;
alert: boolean | null;
alert_config: AlertRule[] | null;
weight: number | null;
}

View file

@ -0,0 +1,45 @@
<div class="flex">
<form [formGroup]="form" (ngSubmit)="submit()">
<textarea
id="contents"
class="w-full textarea textarea-bordered"
placeholder="Paste RadioReference page here"
formControlName="contents"
cols="40" rows="15"
></textarea>
<input type="number" class="input input-bordered" formControlName="systemID" id="systemID" />
<input type="submit" class="btn btn-primary" value="Preview" />
</form>
<button class="btn btn-secondary" (click)="save()">Save</button>
</div>
<div class="w-100 justify-center overflow-x-auto">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" [checked]="isAllSelected()" (change)="selectAllTGs($event)" [(ngModel)]="selectAll" name="selectAll" /></th>
<th>Sys</th>
<th>Sys ID</th>
<th>Group</th>
<th>Alpha</th>
<th>Name</th>
<th>TG ID</th>
<th>Enc</th>
</tr>
</thead>
<tbody>
@for (tg of tgs; track tg.id) {
<tr>
<td><input type="checkbox" class="checkbox" [(ngModel)]="tg.selected" value="{{tg.name}}" (change)="isAllSelected()">
</td>
<td>{{ tg.system?.name }}</td>
<td>{{ tg.system?.id }}</td>
<td>{{ tg.tg_group }}</td>
<td>{{ tg.alpha_tag }}</td>
<td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td>
<td>{{ tg?.metadata?.encrypted ? 'E' : '' }}</td>
</tr>
}
</tbody>
</table>
</div>

View file

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

View file

@ -0,0 +1,77 @@
import { Component, inject } from '@angular/core';
import { TalkgroupService } from '../talkgroups.service';
import { Talkgroup, TalkgroupUI, TalkgroupUpdate } from '../../talkgroup';
import { FormGroup, FormControl, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
@Component({
selector: 'app-import',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule ],
templateUrl: './import.component.html',
styleUrl: './import.component.css',
})
export class ImportComponent {
tgService: TalkgroupService = inject(TalkgroupService);
form!: FormGroup;
tgs!: TalkgroupUI[];
selectAll: boolean = false;
constructor(
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
this.form = new FormGroup({
contents: new FormControl(''),
systemID: new FormControl(0),
});
}
submit() {
let content = this.form.controls['contents'].value;
let sysID = Number(this.form.controls['systemID'].value);
this.tgService.importRR(sysID, content)
.pipe(
catchError(() => {
return of(null);
}),
)
.subscribe((event) => {
this.tgs = event!;
//this.router.navigate(['/talkgroups/']);
});
}
isAllSelected() {
return this.tgs.every(_ => _.selected);
}
selectAllTGs(ev: any) {
this.tgs.forEach(x => x.selected = ev.target.checked);
}
save() {
let toImport: TalkgroupUpdate[] = [];
let sysID = Number(this.form.controls['systemID'].value);
this.tgs.forEach((x) => {
if(x.selected) {
let ct: TalkgroupUpdate = x;
toImport.push(ct);
}
});
this.tgService.putTalkgroups(sysID, toImport).
pipe(
catchError(() => {
return of(null);
}),
)
.subscribe((event) => {
this.tgs = event!;
});
}
}

View file

@ -0,0 +1,33 @@
<div class="container flex">
@for (rule of rules; track $index) {
<div class="">
<table class="table">
<thead>
<tr>
<td>Start</td>
<td>Duration</td>
<td></td>
</tr>
</thead>
<tbody>
@for (time of rule.times; track $index) {
<tr>
<td>
{{ time.time }}
</td>
<td>
{{ time.duration }}
</td>
</tr>
} @empty {
<tr>
<td><em>No times</em></td>
</tr>
}
</tbody>
</table>
</div>
} @empty {
No rules
}
</div>

View file

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

View file

@ -0,0 +1,20 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { AlertRule } from '../../../talkgroup';
@Component({
selector: 'alert-rule-builder',
standalone: true,
imports: [],
templateUrl: './alert-rule-builder.component.html',
styleUrl: './alert-rule-builder.component.css',
})
export class AlertRuleBuilderComponent {
@Input() rules: AlertRule[] = [];
@Output() rulesChange: EventEmitter<AlertRule[]> = new EventEmitter<
AlertRule[]
>();
emit() {
this.rulesChange.emit(this.rules);
}
}

View file

@ -0,0 +1,68 @@
<div class="flex">
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="p-1 columns-2">
<label class="w-full" for="name">Name: </label
><input
id="name"
type="text"
class="w-full input input-bordered"
formControlName="name"
[(ngModel)]="tg.name"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="alpha_tag">Alpha Tag: </label
><input
id="alpha_tag"
type="text"
class="w-full input input-bordered"
formControlName="alpha_tag"
[(ngModel)]="tg.alpha_tag"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="tg_group">Group: </label
><input
id="tg_group"
type="text"
class="w-full input input-bordered"
formControlName="tg_group"
[(ngModel)]="tg.tg_group"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="frequency">Frequency: </label
><input
id="frequency"
type="text"
class="w-full input input-bordered"
formControlName="frequency"
[(ngModel)]="tg.frequency"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="alert">Alert: </label>
<div class="w-full">
<input
id="alert"
type="checkbox"
formControlName="alert"
[(ngModel)]="tg.alert"
class="checkbox"
/>
</div>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="weight">Weight: </label
><input
id="weight"
type="text"
class="w-full input input-bordered"
formControlName="weight"
[(ngModel)]="tg.weight"
/>
</div>
<alert-rule-builder [rules]="tg.alert_config" />
<input type="submit" class="btn btn-secondary" value="Save" />
</form>
</div>

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,89 @@
import { Component, inject } from '@angular/core';
import { Talkgroup, TalkgroupUpdate } from '../../talkgroup';
import { TalkgroupService } from '../talkgroups.service';
import { AlertRuleBuilderComponent } from './alert-rule-builder/alert-rule-builder.component';
import { CommonModule } from '@angular/common';
import { catchError, of } from 'rxjs';
import {
ReactiveFormsModule,
FormGroup,
FormControl,
Validators,
} from '@angular/forms';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { Observable } from 'rxjs';
@Component({
selector: 'talkgroup-record',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, AlertRuleBuilderComponent],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.css',
})
export class TalkgroupRecordComponent {
tg!: Talkgroup;
tgService: TalkgroupService = inject(TalkgroupService);
form!: FormGroup;
constructor(
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
const sysId = this.route.snapshot.paramMap.get('sys');
const tgId = this.route.snapshot.paramMap.get('tg');
this.tgService
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
});
this.form = new FormGroup({
name: new FormControl(''),
alpha_tag: new FormControl(''),
tg_group: new FormControl(''),
frequency: new FormControl(0),
alert: new FormControl(true),
weight: new FormControl(1.0),
});
}
submit() {
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
system_id: this.tg.system_id,
tgid: this.tg.tgid,
id: this.tg.id,
};
if (this.form.controls['name'].dirty) {
tgu.name = this.tg.name;
}
if (this.form.controls['alpha_tag'].dirty) {
tgu.alpha_tag = this.tg.alpha_tag;
}
if (this.form.controls['tg_group'].dirty) {
tgu.tg_group = this.tg.tg_group;
}
if (this.form.controls['frequency'].dirty) {
tgu.frequency = this.tg.frequency;
}
if (this.form.controls['alert'].dirty) {
tgu.alert = this.tg.alert;
}
if (this.form.controls['weight'].dirty) {
tgu.weight = Number(this.tg.weight);
}
this.tgService
.putTalkgroup(tgu)
.pipe(
catchError(() => {
return of(null);
}),
)
.subscribe((event) => {
this.router.navigate(['/talkgroups/']);
});
}
}

View file

@ -0,0 +1,31 @@
<a href="#" class="btn btn-primary" routerLink="/talkgroups/import">Import</a>
<div class="w-100 justify-center overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Sys</th>
<th>Sys ID</th>
<th>Name</th>
<th>TG ID</th>
<th>Learned</th>
<th></th>
</tr>
</thead>
<tbody>
@for (tg of talkgroups$ | async; track tg.id) {
<tr>
<td>{{ tg.system?.name }}</td>
<td>{{ tg.system?.id }}</td>
<td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td>
<td>{{ tg?.learned ? 'Y' : '' }}</td>
<td>
<a routerLink="/talkgroups/{{ tg.system?.id }}/{{ tg.tgid }}"
><ng-icon name="ionCreateOutline"></ng-icon
></a>
</td>
</tr>
}
</tbody>
</table>
</div>

View file

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

View file

@ -0,0 +1,43 @@
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';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { RouterModule, RouterOutlet, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'talkgroups',
standalone: true,
imports: [
NgIconComponent,
RouterOutlet,
RouterModule,
RouterLink,
CommonModule,
],
templateUrl: './talkgroups.component.html',
styleUrl: './talkgroups.component.css',
providers: [provideIcons({ ionCreateOutline })],
})
export class TalkgroupsComponent {
selectedSys: number = 0;
selectedId: number = 0;
talkgroups$!: Observable<Talkgroup[]>;
tgService: TalkgroupService = inject(TalkgroupService);
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.talkgroups$ = this.route.paramMap.pipe(
switchMap((params) => {
this.selectedSys = Number(params.get('sys'));
this.selectedId = Number(params.get('tg'));
return this.tgService.getTalkgroups();
}),
);
}
}

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,39 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Talkgroup, TalkgroupUpdate } 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/');
}
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
return this.http.get<Talkgroup>(`/api/talkgroup/${sys}/${tg}`);
}
importRR(sysID: number, content: string): Observable<Talkgroup[]> {
return this.http.post<Talkgroup[]>('/api/talkgroup/import',
{systemID: sysID, type: 'radioreference', body: content});
}
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
return this.http.put<Talkgroup>(
`/api/talkgroup/${tu.system_id}/${tu.tgid}`,
tu,
);
}
putTalkgroups(sysID: Number, tgs: TalkgroupUpdate[]): Observable<Talkgroup[]> {
return this.http.put<Talkgroup[]>(
`/api/talkgroup/${sysID}`,
tgs,
);
}
}

Binary file not shown.

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<title>Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

7
client/admin/src/main.ts Normal file
View file

@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View file

@ -0,0 +1,9 @@
/* You can add global styles to this file, and also import other style files */
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Warnes";
src: url("./assets/Warnes.ttf");
}

View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,ts}"],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: true,
styled: true,
themes: true,
base: true,
utils: true,
logs: true,
rtl: false,
},
};

View file

@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View file

@ -0,0 +1,30 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View file

@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

View file

@ -36,7 +36,7 @@ notify:
# subjectTemplate: "Stillbox Alert ({{ highest . }})" # subjectTemplate: "Stillbox Alert ({{ highest . }})"
# bodyTemplate: | # bodyTemplate: |
# {{ range . -}} # {{ range . -}}
# {{ .TGName }}{{ if (and .Talkgroup .Talkgroup.AlphaTag) }} ({{ .Talkgroup.StringTag false -}}){{ end }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls) # {{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
# #
# {{ end -}} # {{ end -}}
config: config:

View file

@ -8,7 +8,6 @@ import (
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@ -17,7 +16,6 @@ type Alert struct {
ID int ID int
Timestamp time.Time Timestamp time.Time
TGName string TGName string
Talkgroup *talkgroups.Talkgroup
Score trending.Score[talkgroups.ID] Score trending.Score[talkgroups.ID]
OrigScore float64 OrigScore float64
Weight float32 Weight float32
@ -45,8 +43,7 @@ func (a *Alert) ToAddAlertParams() database.AddAlertParams {
} }
// Make creates an alert for later rendering or storage. // Make creates an alert for later rendering or storage.
func Make(ctx context.Context, score trending.Score[talkgroups.ID], origScore float64) (Alert, error) { func Make(ctx context.Context, store talkgroups.Store, score trending.Score[talkgroups.ID], origScore float64) (Alert, error) {
store := tgstore.FromCtx(ctx)
d := Alert{ d := Alert{
Score: score, Score: score,
Timestamp: time.Now(), Timestamp: time.Now(),
@ -59,7 +56,6 @@ func Make(ctx context.Context, score trending.Score[talkgroups.ID], origScore fl
case nil: case nil:
d.Weight = tgRecord.Talkgroup.Weight d.Weight = tgRecord.Talkgroup.Weight
d.TGName = tgRecord.String() d.TGName = tgRecord.String()
d.Talkgroup = tgRecord
default: default:
system, has := store.SystemName(ctx, int(score.ID.System)) system, has := store.SystemName(ctx, int(score.ID.System))
if has { if has {

View file

@ -3,7 +3,6 @@ package alerting
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand"
"net/http" "net/http"
"sort" "sort"
"sync" "sync"
@ -15,8 +14,7 @@ import (
"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"
"dynatron.me/x/stillbox/pkg/talkgroups" talkgroups "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/internal/timeseries" "dynatron.me/x/stillbox/internal/timeseries"
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
@ -52,7 +50,7 @@ type alerter struct {
alertCache map[talkgroups.ID]alert.Alert alertCache map[talkgroups.ID]alert.Alert
renotify time.Duration renotify time.Duration
notifier notify.Notifier notifier notify.Notifier
tgCache tgstore.Store tgCache talkgroups.Store
} }
type offsetClock time.Duration type offsetClock time.Duration
@ -87,7 +85,7 @@ 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 tgstore.Store, opts ...AlertOption) Alerter { func New(cfg config.Alerting, tgCache talkgroups.Store, opts ...AlertOption) Alerter {
if !cfg.Enable { if !cfg.Enable {
return &noopAlerter{} return &noopAlerter{}
} }
@ -170,7 +168,7 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
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.Weight(ctx, s.ID, now) s.Score *= as.tgCache.Weight(ctx, s.ID, now)
a, err := alert.Make(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)
} }
@ -202,14 +200,6 @@ 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) {
ctx := r.Context() ctx := r.Context()
ridx := rand.Intn(len(as.scores))
a, err := alert.Make(ctx, as.scores[ridx], 1.0)
if err != nil {
log.Error().Err(err).Msg("test notify make alert fail")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
alerts, err := as.eval(ctx, time.Now(), true) alerts, err := as.eval(ctx, time.Now(), true)
if err != nil { if err != nil {
log.Error().Err(err).Msg("test notification eval") log.Error().Err(err).Msg("test notification eval")
@ -217,8 +207,6 @@ func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
alerts = append(alerts, a)
err = as.notifier.Send(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")

View file

@ -13,7 +13,6 @@ import (
"dynatron.me/x/stillbox/internal/trending" "dynatron.me/x/stillbox/internal/trending"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -60,7 +59,7 @@ 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[talkgroups.ID], error) { func (s *Simulation) Simulate(ctx context.Context) (trending.Scores[talkgroups.ID], error) {
now := time.Now() now := time.Now()
tgc := tgstore.NewCache() 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

@ -40,23 +40,6 @@ type jwtAuth interface {
type claims map[string]interface{} type claims map[string]interface{}
func UIDFrom(ctx context.Context) *int32 {
tok, _, err := jwtauth.FromContext(ctx)
if err != nil {
return nil
}
uidStr := tok.Subject()
uidInt, err := strconv.Atoi(uidStr)
if err != nil {
return nil
}
uid := int32(uidInt)
return &uid
}
func (a *Auth) Authenticated(r *http.Request) (claims, bool) { func (a *Auth) Authenticated(r *http.Request) (claims, bool) {
// TODO: check IP against ACL, or conf.Public, and against map of routes // TODO: check IP against ACL, or conf.Public, and against map of routes
tok, cl, err := jwtauth.FromContext(r.Context()) tok, cl, err := jwtauth.FromContext(r.Context())

View file

@ -1,11 +1,13 @@
package calls package calls
import ( import (
"context"
"fmt" "fmt"
"time" "time"
"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/database"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
@ -111,6 +113,21 @@ func (c *Call) ToPB() *pb.Call {
} }
} }
func (c *Call) LearnTG(ctx context.Context, db database.Store) (learnedId int, err error) {
err = db.AddTalkgroupWithLearnedFlag(ctx, int32(c.System), int32(c.Talkgroup))
if err != nil {
return 0, fmt.Errorf("addTalkgroupWithLearnedFlag: %w", err)
}
return db.AddLearnedTalkgroup(ctx, database.AddLearnedTalkgroupParams{
SystemID: c.System,
TGID: c.Talkgroup,
Name: c.TalkgroupLabel,
AlphaTag: c.TGAlphaTag,
TGGroup: c.TalkgroupGroup,
})
}
func (c *Call) computeLength() (err error) { func (c *Call) computeLength() (err error) {
var td time.Duration var td time.Duration

View file

@ -18,83 +18,6 @@ var (
ErrBatchAlreadyClosed = errors.New("batch already closed") ErrBatchAlreadyClosed = errors.New("batch already closed")
) )
const storeTGVersion = `-- name: StoreTGVersion :batchexec
INSERT INTO talkgroup_versions(time, created_by,
system_id,
tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned
) SELECT NOW(), $1,
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,
tg.learned
FROM talkgroups tg WHERE tg.system_id = $2 AND tg.tgid = $3
`
type StoreTGVersionBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type StoreTGVersionParams struct {
Submitter *int32 `json:"submitter"`
SystemID int32 `json:"system_id"`
TGID int32 `json:"tgid"`
}
func (q *Queries) StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Submitter,
a.SystemID,
a.TGID,
}
batch.Queue(storeTGVersion, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &StoreTGVersionBatchResults{br, len(arg), false}
}
func (b *StoreTGVersionBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *StoreTGVersionBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const upsertTalkgroup = `-- name: UpsertTalkgroup :batchone const upsertTalkgroup = `-- name: UpsertTalkgroup :batchone
INSERT INTO talkgroups AS tg ( INSERT INTO talkgroups AS tg (
system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
@ -124,7 +47,7 @@ SET
alert_config = COALESCE($10, tg.alert_config), alert_config = COALESCE($10, tg.alert_config),
weight = COALESCE($11, tg.weight), weight = COALESCE($11, tg.weight),
learned = COALESCE($12, tg.learned) learned = COALESCE($12, tg.learned)
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned, ignored RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
` `
type UpsertTalkgroupBatchResults struct { type UpsertTalkgroupBatchResults struct {
@ -196,7 +119,6 @@ func (b *UpsertTalkgroupBatchResults) QueryRow(f func(int, Talkgroup, error)) {
&i.AlertConfig, &i.AlertConfig,
&i.Weight, &i.Weight,
&i.Learned, &i.Learned,
&i.Ignored,
) )
if f != nil { if f != nil {
f(t, i, err) f(t, i, err)

View file

@ -21,12 +21,8 @@ func (g Talkgroup) GetSystem() System { return S
func (g Talkgroup) GetLearned() bool { return false } func (g Talkgroup) GetLearned() bool { return false }
func (g Talkgroup) String() string { func (g Talkgroup) String() string {
return g.StringTag(true)
}
func (g Talkgroup) StringTag(withTag bool) string {
switch { switch {
case withTag && g.AlphaTag != nil: case g.AlphaTag != nil:
return *g.AlphaTag return *g.AlphaTag
case g.Name != nil && g.TGGroup != nil: case g.Name != nil && g.TGGroup != nil:
return *g.TGGroup + " " + *g.Name return *g.TGGroup + " " + *g.Name

View file

@ -123,22 +123,22 @@ func (_c *Store_AddCall_Call) RunAndReturn(run func(context.Context, database.Ad
} }
// AddLearnedTalkgroup provides a mock function with given fields: ctx, arg // AddLearnedTalkgroup provides a mock function with given fields: ctx, arg
func (_m *Store) AddLearnedTalkgroup(ctx context.Context, arg database.AddLearnedTalkgroupParams) (database.Talkgroup, error) { func (_m *Store) AddLearnedTalkgroup(ctx context.Context, arg database.AddLearnedTalkgroupParams) (int, error) {
ret := _m.Called(ctx, arg) ret := _m.Called(ctx, arg)
if len(ret) == 0 { if len(ret) == 0 {
panic("no return value specified for AddLearnedTalkgroup") panic("no return value specified for AddLearnedTalkgroup")
} }
var r0 database.Talkgroup var r0 int
var r1 error var r1 error
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) (database.Talkgroup, error)); ok { if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) (int, error)); ok {
return rf(ctx, arg) return rf(ctx, arg)
} }
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) database.Talkgroup); ok { if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) int); ok {
r0 = rf(ctx, arg) r0 = rf(ctx, arg)
} else { } else {
r0 = ret.Get(0).(database.Talkgroup) r0 = ret.Get(0).(int)
} }
if rf, ok := ret.Get(1).(func(context.Context, database.AddLearnedTalkgroupParams) error); ok { if rf, ok := ret.Get(1).(func(context.Context, database.AddLearnedTalkgroupParams) error); ok {
@ -169,12 +169,60 @@ func (_c *Store_AddLearnedTalkgroup_Call) Run(run func(ctx context.Context, arg
return _c return _c
} }
func (_c *Store_AddLearnedTalkgroup_Call) Return(_a0 database.Talkgroup, _a1 error) *Store_AddLearnedTalkgroup_Call { func (_c *Store_AddLearnedTalkgroup_Call) Return(_a0 int, _a1 error) *Store_AddLearnedTalkgroup_Call {
_c.Call.Return(_a0, _a1) _c.Call.Return(_a0, _a1)
return _c return _c
} }
func (_c *Store_AddLearnedTalkgroup_Call) RunAndReturn(run func(context.Context, database.AddLearnedTalkgroupParams) (database.Talkgroup, error)) *Store_AddLearnedTalkgroup_Call { func (_c *Store_AddLearnedTalkgroup_Call) RunAndReturn(run func(context.Context, database.AddLearnedTalkgroupParams) (int, error)) *Store_AddLearnedTalkgroup_Call {
_c.Call.Return(run)
return _c
}
// AddTalkgroupWithLearnedFlag provides a mock function with given fields: ctx, systemID, tGID
func (_m *Store) AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) error {
ret := _m.Called(ctx, systemID, tGID)
if len(ret) == 0 {
panic("no return value specified for AddTalkgroupWithLearnedFlag")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int32, int32) error); ok {
r0 = rf(ctx, systemID, tGID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_AddTalkgroupWithLearnedFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTalkgroupWithLearnedFlag'
type Store_AddTalkgroupWithLearnedFlag_Call struct {
*mock.Call
}
// AddTalkgroupWithLearnedFlag is a helper method to define mock.On call
// - ctx context.Context
// - systemID int32
// - tGID int32
func (_e *Store_Expecter) AddTalkgroupWithLearnedFlag(ctx interface{}, systemID interface{}, tGID interface{}) *Store_AddTalkgroupWithLearnedFlag_Call {
return &Store_AddTalkgroupWithLearnedFlag_Call{Call: _e.mock.On("AddTalkgroupWithLearnedFlag", ctx, systemID, tGID)}
}
func (_c *Store_AddTalkgroupWithLearnedFlag_Call) Run(run func(ctx context.Context, systemID int32, tGID int32)) *Store_AddTalkgroupWithLearnedFlag_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(int32), args[2].(int32))
})
return _c
}
func (_c *Store_AddTalkgroupWithLearnedFlag_Call) Return(_a0 error) *Store_AddTalkgroupWithLearnedFlag_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_AddTalkgroupWithLearnedFlag_Call) RunAndReturn(run func(context.Context, int32, int32) error) *Store_AddTalkgroupWithLearnedFlag_Call {
_c.Call.Return(run) _c.Call.Return(run)
return _c return _c
} }
@ -1521,63 +1569,6 @@ func (_c *Store_InTx_Call) RunAndReturn(run func(context.Context, func(database.
return _c return _c
} }
// RestoreTalkgroupVersion provides a mock function with given fields: ctx, versionIds
func (_m *Store) RestoreTalkgroupVersion(ctx context.Context, versionIds int) (database.Talkgroup, error) {
ret := _m.Called(ctx, versionIds)
if len(ret) == 0 {
panic("no return value specified for RestoreTalkgroupVersion")
}
var r0 database.Talkgroup
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int) (database.Talkgroup, error)); ok {
return rf(ctx, versionIds)
}
if rf, ok := ret.Get(0).(func(context.Context, int) database.Talkgroup); ok {
r0 = rf(ctx, versionIds)
} else {
r0 = ret.Get(0).(database.Talkgroup)
}
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, versionIds)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_RestoreTalkgroupVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreTalkgroupVersion'
type Store_RestoreTalkgroupVersion_Call struct {
*mock.Call
}
// RestoreTalkgroupVersion is a helper method to define mock.On call
// - ctx context.Context
// - versionIds int
func (_e *Store_Expecter) RestoreTalkgroupVersion(ctx interface{}, versionIds interface{}) *Store_RestoreTalkgroupVersion_Call {
return &Store_RestoreTalkgroupVersion_Call{Call: _e.mock.On("RestoreTalkgroupVersion", ctx, versionIds)}
}
func (_c *Store_RestoreTalkgroupVersion_Call) Run(run func(ctx context.Context, versionIds int)) *Store_RestoreTalkgroupVersion_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(int))
})
return _c
}
func (_c *Store_RestoreTalkgroupVersion_Call) Return(_a0 database.Talkgroup, _a1 error) *Store_RestoreTalkgroupVersion_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_RestoreTalkgroupVersion_Call) RunAndReturn(run func(context.Context, int) (database.Talkgroup, error)) *Store_RestoreTalkgroupVersion_Call {
_c.Call.Return(run)
return _c
}
// SetCallTranscript provides a mock function with given fields: ctx, iD, transcript // SetCallTranscript provides a mock function with given fields: ctx, iD, transcript
func (_m *Store) SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error { func (_m *Store) SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error {
ret := _m.Called(ctx, iD, transcript) ret := _m.Called(ctx, iD, transcript)
@ -1675,55 +1666,6 @@ func (_c *Store_SetTalkgroupTags_Call) RunAndReturn(run func(context.Context, []
return _c return _c
} }
// StoreTGVersion provides a mock function with given fields: ctx, arg
func (_m *Store) StoreTGVersion(ctx context.Context, arg []database.StoreTGVersionParams) *database.StoreTGVersionBatchResults {
ret := _m.Called(ctx, arg)
if len(ret) == 0 {
panic("no return value specified for StoreTGVersion")
}
var r0 *database.StoreTGVersionBatchResults
if rf, ok := ret.Get(0).(func(context.Context, []database.StoreTGVersionParams) *database.StoreTGVersionBatchResults); ok {
r0 = rf(ctx, arg)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*database.StoreTGVersionBatchResults)
}
}
return r0
}
// Store_StoreTGVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreTGVersion'
type Store_StoreTGVersion_Call struct {
*mock.Call
}
// StoreTGVersion is a helper method to define mock.On call
// - ctx context.Context
// - arg []database.StoreTGVersionParams
func (_e *Store_Expecter) StoreTGVersion(ctx interface{}, arg interface{}) *Store_StoreTGVersion_Call {
return &Store_StoreTGVersion_Call{Call: _e.mock.On("StoreTGVersion", ctx, arg)}
}
func (_c *Store_StoreTGVersion_Call) Run(run func(ctx context.Context, arg []database.StoreTGVersionParams)) *Store_StoreTGVersion_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]database.StoreTGVersionParams))
})
return _c
}
func (_c *Store_StoreTGVersion_Call) Return(_a0 *database.StoreTGVersionBatchResults) *Store_StoreTGVersion_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_StoreTGVersion_Call) RunAndReturn(run func(context.Context, []database.StoreTGVersionParams) *database.StoreTGVersionBatchResults) *Store_StoreTGVersion_Call {
_c.Call.Return(run)
return _c
}
// UpdatePassword provides a mock function with given fields: ctx, username, password // UpdatePassword provides a mock function with given fields: ctx, username, password
func (_m *Store) UpdatePassword(ctx context.Context, username string, password string) error { func (_m *Store) UpdatePassword(ctx context.Context, username string, password string) error {
ret := _m.Called(ctx, username, password) ret := _m.Called(ctx, username, password)

View file

@ -96,26 +96,16 @@ type Talkgroup struct {
AlertConfig rules.AlertRules `json:"alert_config"` AlertConfig rules.AlertRules `json:"alert_config"`
Weight float32 `json:"weight"` Weight float32 `json:"weight"`
Learned bool `json:"learned"` Learned bool `json:"learned"`
Ignored bool `json:"ignored"`
} }
type TalkgroupVersion struct { type TalkgroupsLearned struct {
ID int `json:"id"` ID int `json:"id"`
Time pgtype.Timestamptz `json:"time"` SystemID int `json:"system_id"`
CreatedBy *int32 `json:"created_by"` TGID int `json:"tgid"`
SystemID *int32 `json:"system_id"` Name string `json:"name"`
TGID *int32 `json:"tgid"` AlphaTag *string `json:"alpha_tag"`
Name *string `json:"name"` TGGroup *string `json:"tg_group"`
AlphaTag *string `json:"alpha_tag"` Ignored *bool `json:"ignored"`
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"`
Learned *bool `json:"learned"`
Ignored *bool `json:"ignored"`
} }
type User struct { type User struct {

View file

@ -14,7 +14,8 @@ 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) error AddCall(ctx context.Context, arg AddCallParams) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (int, error)
AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) 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)
DeleteAPIKey(ctx context.Context, apiKey string) error DeleteAPIKey(ctx context.Context, apiKey string) error
@ -34,10 +35,8 @@ type Querier interface {
GetUserByUID(ctx context.Context, id int) (User, error) GetUserByUID(ctx context.Context, id int) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error)
GetUsers(ctx context.Context) ([]User, error) GetUsers(ctx context.Context) ([]User, error)
RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error)
SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults
UpdatePassword(ctx context.Context, username string, password string) error UpdatePassword(ctx context.Context, username string, password string) error
UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams) (Talkgroup, error) UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams) (Talkgroup, error)
UpsertTalkgroup(ctx context.Context, arg []UpsertTalkgroupParams) *UpsertTalkgroupBatchResults UpsertTalkgroup(ctx context.Context, arg []UpsertTalkgroupParams) *UpsertTalkgroupBatchResults

View file

@ -41,11 +41,20 @@ func (t *TGTuples) Append(sys, tg uint32) {
// Below queries are here because sqlc refuses to parse unnest(x, y) // Below queries are here because sqlc refuses to parse unnest(x, y)
const getTalkgroupsWithLearnedBySysTGID = `SELECT const getTalkgroupsWithLearnedBySysTGID = `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, tg.learned, tg.ignored 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.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
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg) JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg)
WHERE tg.learned IS NOT TRUE;` WHERE tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, sys.id, sys.name, TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tgl.system_id = tgt.sys AND tgl.tgid = tgt.tg);`
type GetTalkgroupsRow struct { type GetTalkgroupsRow struct {
Talkgroup Talkgroup `json:"talkgroup"` Talkgroup Talkgroup `json:"talkgroup"`
@ -77,7 +86,6 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
&i.System.ID, &i.System.ID,
&i.System.Name, &i.System.Name,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -89,7 +97,7 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
return items, nil return items, nil
} }
const getTalkgroupsBySysTGID = `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, tg.learned, tg.ignored, sys.id, sys.name FROM talkgroups tg const getTalkgroupsBySysTGID = `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
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg) JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg)
WHERE tg.learned IS NOT TRUE;` WHERE tg.learned IS NOT TRUE;`
@ -116,8 +124,6 @@ func (q *Queries) GetTalkgroupsBySysTGID(ctx context.Context, ids TGTuples) ([]G
&i.Talkgroup.Alert, &i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
&i.System.ID, &i.System.ID,
&i.System.Name, &i.System.Name,
); err != nil { ); err != nil {

View file

@ -13,32 +13,30 @@ import (
) )
const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one
INSERT INTO talkgroups( INSERT INTO talkgroups_learned(
system_id, system_id,
tgid, tgid,
learned,
name, name,
alpha_tag, alpha_tag,
tg_group tg_group
) VALUES ( ) VALUES (
$1, $1,
$2, $2,
TRUE,
$3, $3,
$4, $4,
$5 $5
) RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned, ignored ) RETURNING id
` `
type AddLearnedTalkgroupParams struct { type AddLearnedTalkgroupParams struct {
SystemID int32 `json:"system_id"` SystemID int `json:"system_id"`
TGID int32 `json:"tgid"` TGID int `json:"tgid"`
Name *string `json:"name"` Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"` AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tg_group"`
} }
func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) { func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (int, error) {
row := q.db.QueryRow(ctx, addLearnedTalkgroup, row := q.db.QueryRow(ctx, addLearnedTalkgroup,
arg.SystemID, arg.SystemID,
arg.TGID, arg.TGID,
@ -46,24 +44,26 @@ func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgro
arg.AlphaTag, arg.AlphaTag,
arg.TGGroup, arg.TGGroup,
) )
var i Talkgroup var id int
err := row.Scan( err := row.Scan(&id)
&i.ID, return id, err
&i.SystemID, }
&i.TGID,
&i.Name, const addTalkgroupWithLearnedFlag = `-- name: AddTalkgroupWithLearnedFlag :exec
&i.AlphaTag, INSERT INTO talkgroups (
&i.TGGroup, system_id,
&i.Frequency, tgid,
&i.Metadata, learned
&i.Tags, ) VALUES(
&i.Alert, $1,
&i.AlertConfig, $2,
&i.Weight, TRUE
&i.Learned, )
&i.Ignored, `
)
return i, err func (q *Queries) AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) error {
_, err := q.db.Exec(ctx, addTalkgroupWithLearnedFlag, systemID, tGID)
return err
} }
const getSystemName = `-- name: GetSystemName :one const getSystemName = `-- name: GetSystemName :one
@ -78,7 +78,7 @@ func (q *Queries) GetSystemName(ctx context.Context, systemID int) (string, erro
} }
const getTalkgroup = `-- name: GetTalkgroup :one const getTalkgroup = `-- name: GetTalkgroup :one
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, talkgroups.learned, talkgroups.ignored 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, talkgroups.learned FROM talkgroups
WHERE (system_id, tgid) = ($1, $2) WHERE (system_id, tgid) = ($1, $2)
` `
@ -103,7 +103,6 @@ func (q *Queries) GetTalkgroup(ctx context.Context, systemID int32, tGID int32)
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
) )
return i, err return i, err
} }
@ -154,10 +153,19 @@ func (q *Queries) GetTalkgroupTags(ctx context.Context, systemID int32, tGID int
const getTalkgroupWithLearned = `-- name: GetTalkgroupWithLearned :one const getTalkgroupWithLearned = `-- name: GetTalkgroupWithLearned :one
SELECT 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, tg.learned, tg.ignored, sys.id, sys.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, tg.learned, sys.id, sys.name
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.system_id, tg.tgid) = ($1, $2) WHERE (tg.system_id, tg.tgid) = ($1, $2) AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
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
` `
type GetTalkgroupWithLearnedRow struct { type GetTalkgroupWithLearnedRow struct {
@ -182,7 +190,6 @@ func (q *Queries) GetTalkgroupWithLearned(ctx context.Context, systemID int32, t
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
&i.System.ID, &i.System.ID,
&i.System.Name, &i.System.Name,
) )
@ -190,7 +197,7 @@ func (q *Queries) GetTalkgroupWithLearned(ctx context.Context, systemID int32, t
} }
const getTalkgroupsWithAllTags = `-- name: GetTalkgroupsWithAllTags :many const getTalkgroupsWithAllTags = `-- name: GetTalkgroupsWithAllTags :many
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, talkgroups.learned, talkgroups.ignored 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, talkgroups.learned FROM talkgroups
WHERE tags && ARRAY[$1] WHERE tags && ARRAY[$1]
` `
@ -221,7 +228,6 @@ func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) (
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -234,7 +240,7 @@ func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) (
} }
const getTalkgroupsWithAnyTags = `-- name: GetTalkgroupsWithAnyTags :many const getTalkgroupsWithAnyTags = `-- name: GetTalkgroupsWithAnyTags :many
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, talkgroups.learned, talkgroups.ignored 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, talkgroups.learned FROM talkgroups
WHERE tags @> ARRAY[$1] WHERE tags @> ARRAY[$1]
` `
@ -265,7 +271,6 @@ func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) (
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -279,9 +284,18 @@ func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) (
const getTalkgroupsWithLearned = `-- name: GetTalkgroupsWithLearned :many const getTalkgroupsWithLearned = `-- name: GetTalkgroupsWithLearned :many
SELECT 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, tg.learned, tg.ignored, sys.id, sys.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, tg.learned, sys.id, sys.name
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.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE WHERE ignored IS NOT TRUE
` `
@ -313,7 +327,6 @@ func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroups
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
&i.System.ID, &i.System.ID,
&i.System.Name, &i.System.Name,
); err != nil { ); err != nil {
@ -329,10 +342,19 @@ func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroups
const getTalkgroupsWithLearnedBySystem = `-- name: GetTalkgroupsWithLearnedBySystem :many const getTalkgroupsWithLearnedBySystem = `-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT 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, tg.learned, tg.ignored, sys.id, sys.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, tg.learned, sys.id, sys.name
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.system_id = $1 WHERE tg.system_id = $1 AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
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 { type GetTalkgroupsWithLearnedBySystemRow struct {
@ -363,7 +385,6 @@ func (q *Queries) GetTalkgroupsWithLearnedBySystem(ctx context.Context, system i
&i.Talkgroup.AlertConfig, &i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight, &i.Talkgroup.Weight,
&i.Talkgroup.Learned, &i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
&i.System.ID, &i.System.ID,
&i.System.Name, &i.System.Name,
); err != nil { ); err != nil {
@ -377,73 +398,6 @@ func (q *Queries) GetTalkgroupsWithLearnedBySystem(ctx context.Context, system i
return items, nil return items, nil
} }
const restoreTalkgroupVersion = `-- name: RestoreTalkgroupVersion :one
INSERT INTO talkgroups(
system_id,
tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned,
ignored
)
SELECT
system_id,
tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned,
ignored
FROM talkgroup_versions tgv ON CONFLICT (system_id, tgid) DO UPDATE SET
name = excluded.name,
alpha_tag = excluded.alpha_tag,
tg_group = excluded.tg_group,
metadata = excluded.metadata,
tags = excluded.tags,
alert = excluded.alert,
alert_config = excluded.alert_config,
weight = excluded.weight,
learned = excluded.learner,
ignored = excluded.ignored
WHERE tgv.id = ANY($1)
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned, ignored
`
func (q *Queries) RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error) {
row := q.db.QueryRow(ctx, restoreTalkgroupVersion, versionIds)
var i Talkgroup
err := row.Scan(
&i.ID,
&i.SystemID,
&i.TGID,
&i.Name,
&i.AlphaTag,
&i.TGGroup,
&i.Frequency,
&i.Metadata,
&i.Tags,
&i.Alert,
&i.AlertConfig,
&i.Weight,
&i.Learned,
&i.Ignored,
)
return i, err
}
const setTalkgroupTags = `-- name: SetTalkgroupTags :exec const setTalkgroupTags = `-- name: SetTalkgroupTags :exec
UPDATE talkgroups SET tags = $1 UPDATE talkgroups SET tags = $1
WHERE system_id = $2 AND tgid = $3 WHERE system_id = $2 AND tgid = $3
@ -468,7 +422,7 @@ SET
weight = COALESCE($9, weight), weight = COALESCE($9, weight),
learned = COALESCE($10, learned) learned = COALESCE($10, learned)
WHERE id = $11 OR (system_id = $12 AND tgid = $13) WHERE id = $11 OR (system_id = $12 AND tgid = $13)
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned, ignored RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
` `
type UpdateTalkgroupParams struct { type UpdateTalkgroupParams struct {
@ -518,7 +472,6 @@ func (q *Queries) UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams
&i.AlertConfig, &i.AlertConfig,
&i.Weight, &i.Weight,
&i.Learned, &i.Learned,
&i.Ignored,
) )
return i, err return i, err
} }

View file

@ -0,0 +1,64 @@
package database
import (
"testing"
"github.com/stretchr/testify/assert"
)
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, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE (tg.system_id, tg.tgid) = ($1, $2) AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
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, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = $1 AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.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, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE
`
func TestQueryColumnsMatch(t *testing.T) {
assert.Equal(t, getTalkgroupWithLearnedTest, getTalkgroupWithLearned)
assert.Equal(t, getTalkgroupsWithLearnedBySystemTest, getTalkgroupsWithLearnedBySystem)
assert.Equal(t, getTalkgroupsWithLearnedTest, getTalkgroupsWithLearned)
}

View file

@ -6,7 +6,6 @@ import (
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
@ -60,9 +59,9 @@ 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 {
tgi, err := tgstore.FromCtx(ctx).TG(ctx, talkgroups.TG(tg.System, tg.Talkgroup)) tgi, err := talkgroups.StoreFrom(ctx).TG(ctx, talkgroups.TG(tg.System, tg.Talkgroup))
if err != nil { if err != nil {
if err != tgstore.ErrNotFound { 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

View file

@ -52,7 +52,7 @@ var alertFm = template.FuncMap{
const ( const (
defaultBodyTemplStr = `{{ range . -}} defaultBodyTemplStr = `{{ range . -}}
{{ .TGName }}{{ if (and .Talkgroup .Talkgroup.AlphaTag) }} ({{ .Talkgroup.StringTag false -}}){{ end }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls) {{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
{{ end -}}` {{ end -}}`
defaultSubjectTemplStr = `Stillbox Alert ({{ highest . }})` defaultSubjectTemplStr = `Stillbox Alert ({{ highest . }})`

View file

@ -4,7 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -67,15 +67,6 @@ func recordNotFound(err error) render.Renderer {
} }
} }
func errTextNotFound(err error) render.Renderer {
return &errResponse{
Err: err,
Code: http.StatusNotFound,
Error: "Record not found: " + err.Error(),
}
}
func internalError(err error) render.Renderer { func internalError(err error) render.Renderer {
return &errResponse{ return &errResponse{
Err: err, Err: err,
@ -87,8 +78,8 @@ func internalError(err error) render.Renderer {
type errResponder func(error) render.Renderer type errResponder func(error) render.Renderer
var statusMapping = map[error]errResponder{ var statusMapping = map[error]errResponder{
tgstore.ErrNoSuchSystem: errTextNotFound, talkgroups.ErrNoSuchSystem: recordNotFound,
tgstore.ErrNotFound: errTextNotFound, talkgroups.ErrNotFound: recordNotFound,
pgx.ErrNoRows: recordNotFound, pgx.ErrNoRows: recordNotFound,
} }

View file

@ -6,7 +6,6 @@ import (
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/importer" "dynatron.me/x/stillbox/pkg/talkgroups/importer"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -54,7 +53,7 @@ func (t tgParams) ToID() talkgroups.ID {
func (tga *talkgroupAPI) get(w http.ResponseWriter, r *http.Request) { func (tga *talkgroupAPI) get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
tgs := tgstore.FromCtx(ctx) tgs := talkgroups.StoreFrom(ctx)
var p tgParams var p tgParams
@ -92,7 +91,7 @@ func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) {
} }
ctx := r.Context() ctx := r.Context()
tgs := tgstore.FromCtx(ctx) tgs := talkgroups.StoreFrom(ctx)
input := database.UpdateTalkgroupParams{} input := database.UpdateTalkgroupParams{}
@ -138,12 +137,12 @@ func (tga *talkgroupAPI) putTalkgroups(w http.ResponseWriter, r *http.Request) {
} }
if id.System == nil { // don't think this would ever happen if id.System == nil { // don't think this would ever happen
wErr(w, r, badRequest(tgstore.ErrNoSuchSystem)) wErr(w, r, badRequest(talkgroups.ErrNoSuchSystem))
return return
} }
ctx := r.Context() ctx := r.Context()
tgs := tgstore.FromCtx(ctx) tgs := talkgroups.StoreFrom(ctx)
var input []database.UpsertTalkgroupParams var input []database.UpsertTalkgroupParams

View file

@ -9,7 +9,7 @@ import (
"dynatron.me/x/stillbox/internal/version" "dynatron.me/x/stillbox/internal/version"
"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/tgstore" "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/httprate" "github.com/go-chi/httprate"
@ -28,7 +28,7 @@ func (s *Server) setupRoutes() {
r := s.r r := s.r
r.Use(middleware.WithValue(database.DBCtxKey, s.db)) r.Use(middleware.WithValue(database.DBCtxKey, s.db))
r.Use(middleware.WithValue(tgstore.StoreCtxKey, s.tgs)) r.Use(middleware.WithValue(talkgroups.StoreCtxKey, s.tgs))
s.installPprof() s.installPprof()

View file

@ -15,8 +15,7 @@ import (
"dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/rest"
"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/tgstore" "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"
@ -38,7 +37,7 @@ type Server struct {
alerter alerting.Alerter alerter alerting.Alerter
notifier notify.Notifier notifier notify.Notifier
hup chan os.Signal hup chan os.Signal
tgs tgstore.Store tgs talkgroups.Store
rest rest.API rest rest.API
} }
@ -62,7 +61,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
return nil, err return nil, err
} }
tgCache := tgstore.NewCache() tgCache := talkgroups.NewCache()
api := rest.New() api := rest.New()
srv := &Server{ srv := &Server{
@ -79,7 +78,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
rest: api, rest: api,
} }
srv.sinks.Register("database", sinks.NewDatabaseSink(srv.db, tgCache), true) srv.sinks.Register("database", sinks.NewDatabaseSink(srv.db), true)
srv.sinks.Register("nexus", sinks.NewNexusSink(srv.nex), false) srv.sinks.Register("nexus", sinks.NewNexusSink(srv.nex), false)
if srv.alerter.Enabled() { if srv.alerter.Enabled() {
@ -118,7 +117,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 = tgstore.CtxWithStore(ctx, s.tgs) ctx = talkgroups.CtxWithStore(ctx, s.tgs)
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: s.conf.Listen, Addr: s.conf.Listen,

View file

@ -7,7 +7,6 @@ import (
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@ -16,11 +15,10 @@ import (
type DatabaseSink struct { type DatabaseSink struct {
db database.Store db database.Store
tgs tgstore.Store
} }
func NewDatabaseSink(store database.Store, tgs tgstore.Store) *DatabaseSink { func NewDatabaseSink(store database.Store) *DatabaseSink {
return &DatabaseSink{store, tgs} return &DatabaseSink{store}
} }
func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error { func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
@ -45,14 +43,14 @@ func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
if err != nil && database.IsTGConstraintViolation(err) { if err != nil && database.IsTGConstraintViolation(err) {
return s.db.InTx(ctx, func(tx database.Store) error { return s.db.InTx(ctx, func(tx database.Store) error {
_, err := s.tgs.LearnTG(ctx, call) _, err := call.LearnTG(ctx, tx)
if err != nil { if err != nil {
return fmt.Errorf("learn tg: %w", err) return fmt.Errorf("add call: learn tg: %w", err)
} }
err = tx.AddCall(ctx, params) err = tx.AddCall(ctx, params)
if err != nil { if err != nil {
return fmt.Errorf("learn tg retry: %w", err) return fmt.Errorf("add call: retry: %w", err)
} }
return nil return nil

View file

@ -131,7 +131,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Info().Int("system", cur.System).Int("tgid", cur.Talkgroup).Str("duration", call.Duration.Duration().String()).Msg("ingested") log.Info().Int("system", cur.System).Int("tgid", cur.Talkgroup).Msg("ingested")
written, err := w.Write([]byte("Call imported successfully.")) written, err := w.Write([]byte("Call imported successfully."))
if err != nil { if err != nil {

View file

@ -13,7 +13,6 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
) )
type ImportSource string type ImportSource string
@ -67,9 +66,9 @@ var rrRE = regexp.MustCompile(`DEC\s+HEX\s+Mode\s+Alpha Tag\s+Description\s+Tag`
func (rr *radioReferenceImporter) importTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error) { func (rr *radioReferenceImporter) importTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error) {
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
tgs := make([]talkgroups.Talkgroup, 0, 8) tgs := make([]talkgroups.Talkgroup, 0, 8)
sysn, has := tgstore.FromCtx(ctx).SystemName(ctx, sys) sysn, has := talkgroups.StoreFrom(ctx).SystemName(ctx, sys)
if !has { if !has {
return nil, tgstore.ErrNoSuchSystem return nil, talkgroups.ErrNoSuchSystem
} }
var groupName string var groupName string
@ -120,7 +119,6 @@ func (rr *radioReferenceImporter) importTalkgroups(ctx context.Context, sys int,
Metadata: metadata, Metadata: metadata,
Tags: tags, Tags: tags,
Weight: 1.0, Weight: 1.0,
Alert: true,
}, },
System: database.System{ System: database.System{
ID: sys, ID: sys,

View file

@ -16,7 +16,6 @@ import (
"dynatron.me/x/stillbox/pkg/database/mocks" "dynatron.me/x/stillbox/pkg/database/mocks"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/importer" "dynatron.me/x/stillbox/pkg/talkgroups/importer"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
) )
func getFixture(fixture string) []byte { func getFixture(fixture string) []byte {
@ -63,7 +62,7 @@ func TestImport(t *testing.T) {
dbMock.EXPECT().GetSystemName(mock.AnythingOfType("*context.valueCtx"), tc.sysID).Return(tc.sysName, nil) dbMock.EXPECT().GetSystemName(mock.AnythingOfType("*context.valueCtx"), tc.sysID).Return(tc.sysName, nil)
} }
ctx := database.CtxWithDB(context.Background(), dbMock) ctx := database.CtxWithDB(context.Background(), dbMock)
ctx = tgstore.CtxWithStore(ctx, tgstore.NewCache()) ctx = talkgroups.CtxWithStore(ctx, talkgroups.NewCache())
ij := &importer.ImportJob{ ij := &importer.ImportJob{
Type: importer.ImportSource(tc.impType), Type: importer.ImportSource(tc.impType),
SystemID: tc.sysID, SystemID: tc.sysID,

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
package tgstore package talkgroups
import ( import (
"context" "context"
@ -8,17 +8,14 @@ import (
"time" "time"
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"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"
"dynatron.me/x/stillbox/pkg/calls"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type tgMap map[tgsp.ID]*tgsp.Talkgroup type tgMap map[ID]*Talkgroup
var ( var (
ErrNotFound = errors.New("talkgroup not found") ErrNotFound = errors.New("talkgroup not found")
@ -27,28 +24,25 @@ var (
type Store interface { type Store interface {
// UpdateTG updates a talkgroup record. // UpdateTG updates a talkgroup record.
UpdateTG(ctx context.Context, input database.UpdateTalkgroupParams) (*tgsp.Talkgroup, error) UpdateTG(ctx context.Context, input database.UpdateTalkgroupParams) (*Talkgroup, error)
// UpsertTGs upserts a slice of talkgroups. // UpsertTGs upserts a slice of talkgroups.
UpsertTGs(ctx context.Context, system int, input []database.UpsertTalkgroupParams) ([]*tgsp.Talkgroup, error) UpsertTGs(ctx context.Context, system int, input []database.UpsertTalkgroupParams) ([]*Talkgroup, error)
// TG retrieves a Talkgroup from the Store. // TG retrieves a Talkgroup from the Store.
TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) TG(ctx context.Context, tg ID) (*Talkgroup, error)
// TGs retrieves many talkgroups from the Store. // TGs retrieves many talkgroups from the Store.
TGs(ctx context.Context, tgs tgsp.IDs) ([]*tgsp.Talkgroup, error) TGs(ctx context.Context, tgs IDs) ([]*Talkgroup, error)
// LearnTG learns the talkgroup from a Call.
LearnTG(ctx context.Context, call *calls.Call) (*tgsp.Talkgroup, error)
// SystemTGs retrieves all Talkgroups associated with a System. // SystemTGs retrieves all Talkgroups associated with a System.
SystemTGs(ctx context.Context, systemID int32) ([]*tgsp.Talkgroup, error) 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 retrieves a system name from the store. It returns the record and whether one was found.
SystemName(ctx context.Context, id int) (string, bool) SystemName(ctx context.Context, id int) (string, bool)
// Hint hints the Store that the provided talkgroups will be asked for. // Hint hints the Store that the provided talkgroups will be asked for.
Hint(ctx context.Context, tgs []tgsp.ID) error Hint(ctx context.Context, tgs []ID) error
// Load loads the provided talkgroup ID tuples into the Store. // Load loads the provided talkgroup ID tuples into the Store.
Load(ctx context.Context, tgs database.TGTuples) error Load(ctx context.Context, tgs database.TGTuples) error
@ -57,7 +51,7 @@ type Store interface {
Invalidate() Invalidate()
// Weight returns the final weight of this talkgroup, including its static and rules-derived weight. // Weight returns the final weight of this talkgroup, including its static and rules-derived weight.
Weight(ctx context.Context, id tgsp.ID, t time.Time) float64 Weight(ctx context.Context, id ID, t time.Time) float64
// Hupper // Hupper
HUP(*config.Config) HUP(*config.Config)
@ -71,7 +65,7 @@ func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return context.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func StoreFrom(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := ctx.Value(StoreCtxKey).(Store)
if !ok { if !ok {
return NewCache() return NewCache()
@ -107,7 +101,7 @@ func NewCache() Store {
return tgc return tgc
} }
func (t *cache) Hint(ctx context.Context, tgs []tgsp.ID) error { func (t *cache) Hint(ctx context.Context, tgs []ID) error {
t.RLock() t.RLock()
var toLoad database.TGTuples var toLoad database.TGTuples
if len(t.tgs) > len(tgs)/2 { // TODO: instrument this if len(t.tgs) > len(tgs)/2 { // TODO: instrument this
@ -135,11 +129,11 @@ func (t *cache) Hint(ctx context.Context, tgs []tgsp.ID) error {
return nil return nil
} }
func (t *cache) add(rec *tgsp.Talkgroup) { func (t *cache) add(rec *Talkgroup) {
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
tg := tgsp.TG(rec.System.ID, rec.Talkgroup.TGID) tg := TG(rec.System.ID, rec.Talkgroup.TGID)
t.tgs[tg] = rec t.tgs[tg] = rec
t.systems[int32(rec.System.ID)] = rec.System.Name t.systems[int32(rec.System.ID)] = rec.System.Name
} }
@ -152,15 +146,15 @@ type row interface {
GetLearned() bool GetLearned() bool
} }
func rowToTalkgroup[T row](r T) *tgsp.Talkgroup { func rowToTalkgroup[T row](r T) *Talkgroup {
return &tgsp.Talkgroup{ return &Talkgroup{
Talkgroup: r.GetTalkgroup(), Talkgroup: r.GetTalkgroup(),
System: r.GetSystem(), System: r.GetSystem(),
Learned: r.GetLearned(), Learned: r.GetLearned(),
} }
} }
func addToRowList[T row](t *cache, r []*tgsp.Talkgroup, tgRecords []T) []*tgsp.Talkgroup { func addToRowList[T row](t *cache, r []*Talkgroup, tgRecords []T) []*Talkgroup {
for _, rec := range tgRecords { for _, rec := range tgRecords {
tg := rowToTalkgroup(rec) tg := rowToTalkgroup(rec)
t.add(tg) t.add(tg)
@ -171,11 +165,11 @@ func addToRowList[T row](t *cache, r []*tgsp.Talkgroup, tgRecords []T) []*tgsp.T
return r return r
} }
func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs) ([]*tgsp.Talkgroup, error) { func (t *cache) TGs(ctx context.Context, tgs IDs) ([]*Talkgroup, error) {
r := make([]*tgsp.Talkgroup, 0, len(tgs)) r := make([]*Talkgroup, 0, len(tgs))
var err error var err error
if tgs != nil { if tgs != nil {
toGet := make(tgsp.IDs, 0, len(tgs)) toGet := make(IDs, 0, len(tgs))
t.RLock() t.RLock()
for _, id := range tgs { for _, id := range tgs {
rec, has := t.tgs[id] rec, has := t.tgs[id]
@ -216,7 +210,7 @@ func (t *cache) Load(ctx context.Context, tgs database.TGTuples) error {
return nil return nil
} }
func (t *cache) Weight(ctx context.Context, id tgsp.ID, tm time.Time) float64 { func (t *cache) Weight(ctx context.Context, id ID, tm time.Time) float64 {
tg, err := t.TG(ctx, id) tg, err := t.TG(ctx, id)
if err != nil { if err != nil {
return 1.0 return 1.0
@ -229,17 +223,17 @@ func (t *cache) Weight(ctx context.Context, id tgsp.ID, tm time.Time) float64 {
return float64(m) return float64(m)
} }
func (t *cache) SystemTGs(ctx context.Context, systemID int32) ([]*tgsp.Talkgroup, error) { func (t *cache) SystemTGs(ctx context.Context, systemID int32) ([]*Talkgroup, error) {
recs, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedBySystem(ctx, systemID) recs, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedBySystem(ctx, systemID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r := make([]*tgsp.Talkgroup, 0, len(recs)) r := make([]*Talkgroup, 0, len(recs))
return addToRowList(t, r, recs), nil return addToRowList(t, r, recs), nil
} }
func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) { func (t *cache) TG(ctx context.Context, tg ID) (*Talkgroup, error) {
t.RLock() t.RLock()
rec, has := t.tgs[tg] rec, has := t.tgs[tg]
t.RUnlock() t.RUnlock()
@ -284,7 +278,7 @@ func (t *cache) SystemName(ctx context.Context, id int) (name string, has bool)
return n, has return n, has
} }
func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupParams) (*tgsp.Talkgroup, error) { func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupParams) (*Talkgroup, error) {
sysName, has := t.SystemName(ctx, int(*input.SystemID)) sysName, has := t.SystemName(ctx, int(*input.SystemID))
if !has { if !has {
return nil, ErrNoSuchSystem return nil, ErrNoSuchSystem
@ -295,7 +289,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
return nil, err return nil, err
} }
record := &tgsp.Talkgroup{ record := &Talkgroup{
Talkgroup: tg, Talkgroup: tg,
System: database.System{ID: int(tg.SystemID), Name: sysName}, System: database.System{ID: int(tg.SystemID), Name: sysName},
} }
@ -304,40 +298,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
return record, nil return record, nil
} }
func (t *cache) LearnTG(ctx context.Context, c *calls.Call) (*tgsp.Talkgroup, error) { func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.UpsertTalkgroupParams) ([]*Talkgroup, error) {
db := database.FromCtx(ctx)
sys, has := t.SystemName(ctx, c.System)
if !has {
return nil, ErrNoSuchSystem
}
tgm, err := db.AddLearnedTalkgroup(ctx, database.AddLearnedTalkgroupParams{
SystemID: int32(c.System),
TGID: int32(c.Talkgroup),
Name: c.TalkgroupLabel,
AlphaTag: c.TGAlphaTag,
TGGroup: c.TalkgroupGroup,
})
if err != nil {
return nil, err
}
tg := &tgsp.Talkgroup{
Talkgroup: tgm,
System: database.System{
ID: c.System,
Name: sys,
},
Learned: tgm.Learned,
}
t.add(tg)
return tg, nil
}
func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.UpsertTalkgroupParams) ([]*tgsp.Talkgroup, error) {
db := database.FromCtx(ctx) db := database.FromCtx(ctx)
sysName, hasSys := t.SystemName(ctx, system) sysName, hasSys := t.SystemName(ctx, system)
if !hasSys { if !hasSys {
@ -348,10 +309,9 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
Name: sysName, Name: sysName,
} }
tgs := make([]*tgsp.Talkgroup, 0, len(input)) tgs := make([]*Talkgroup, 0, len(input))
err := db.InTx(ctx, func(db database.Store) error { err := db.InTx(ctx, func(db database.Store) error {
versionParams := make([]database.StoreTGVersionParams, 0, len(input))
for i := range input { for i := range input {
// normalize tags // normalize tags
for j, tag := range input[i].Tags { for j, tag := range input[i].Tags {
@ -361,25 +321,19 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
input[i].SystemID = int32(system) input[i].SystemID = int32(system)
input[i].Learned = common.PtrTo(false) input[i].Learned = common.PtrTo(false)
} }
var oerr error var oerr error
tgUpsertBatch := db.UpsertTalkgroup(ctx, input) batch := db.UpsertTalkgroup(ctx, input)
defer tgUpsertBatch.Close() defer batch.Close()
tgUpsertBatch.QueryRow(func(_ int, r database.Talkgroup, err error) { batch.QueryRow(func(_ int, r database.Talkgroup, err error) {
if err != nil { if err != nil {
oerr = err oerr = err
return return
} }
versionParams = append(versionParams, database.StoreTGVersionParams{ tgs = append(tgs, &Talkgroup{
SystemID: int32(system),
TGID: r.TGID,
Submitter: auth.UIDFrom(ctx),
})
tgs = append(tgs, &tgsp.Talkgroup{
Talkgroup: r, Talkgroup: r,
System: sys, System: sys,
Learned: r.Learned, Learned: r.Learned,
@ -390,17 +344,7 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
return oerr return oerr
} }
versionBatch := db.StoreTGVersion(ctx, versionParams) return nil
defer versionBatch.Close()
versionBatch.Exec(func(_ int, err error) {
if err != nil {
oerr = err
return
}
})
return oerr
}, pgx.TxOptions{}) }, pgx.TxOptions{})
if err != nil { if err != nil {

View file

@ -37,7 +37,6 @@ CREATE TABLE IF NOT EXISTS talkgroups(
alert_config JSONB, alert_config JSONB,
weight REAL NOT NULL DEFAULT 1.0, weight REAL NOT NULL DEFAULT 1.0,
learned BOOLEAN NOT NULL DEFAULT FALSE, learned BOOLEAN NOT NULL DEFAULT FALSE,
ignored BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (system_id, tgid) UNIQUE (system_id, tgid)
); );
@ -45,25 +44,15 @@ CREATE INDEX talkgroups_system_tgid_idx ON talkgroups (system_id, tgid);
CREATE INDEX IF NOT EXISTS talkgroup_id_tags ON talkgroups USING GIN (tags); CREATE INDEX IF NOT EXISTS talkgroup_id_tags ON talkgroups USING GIN (tags);
CREATE TABLE IF NOT EXISTS talkgroup_versions( CREATE TABLE IF NOT EXISTS talkgroups_learned(
-- version metadata
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
time TIMESTAMPTZ NOT NULL, system_id INTEGER REFERENCES systems(id) NOT NULL,
created_by INTEGER REFERENCES users(id), tgid INTEGER NOT NULL,
-- talkgroup snapshot name TEXT NOT NULL,
system_id INT4 REFERENCES systems(id),
tgid INT4,
name TEXT,
alpha_tag TEXT, alpha_tag TEXT,
tg_group TEXT, tg_group TEXT,
frequency INTEGER, ignored BOOLEAN,
metadata JSONB, UNIQUE (system_id, tgid, name)
tags TEXT[],
alert BOOLEAN,
alert_config JSONB,
weight REAL,
learned BOOLEAN,
ignored BOOLEAN
); );
CREATE TABLE IF NOT EXISTS alerts( CREATE TABLE IF NOT EXISTS alerts(

View file

@ -29,20 +29,47 @@ SELECT
sqlc.embed(tg), sqlc.embed(sys) sqlc.embed(tg), sqlc.embed(sys)
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.system_id, tg.tgid) = (@system_id, @tgid); WHERE (tg.system_id, tg.tgid) = (@system_id, @tgid) AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = @system_id AND tgl.tgid = @tgid AND ignored IS NOT TRUE;
-- name: GetTalkgroupsWithLearnedBySystem :many -- name: GetTalkgroupsWithLearnedBySystem :many
SELECT SELECT
sqlc.embed(tg), sqlc.embed(sys) sqlc.embed(tg), sqlc.embed(sys)
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.system_id = @system; WHERE tg.system_id = @system AND tg.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
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 -- name: GetTalkgroupsWithLearned :many
SELECT SELECT
sqlc.embed(tg), sqlc.embed(sys) sqlc.embed(tg), sqlc.embed(sys)
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.learned IS NOT TRUE
UNION
SELECT
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE; WHERE ignored IS NOT TRUE;
-- name: GetSystemName :one -- name: GetSystemName :one
@ -95,92 +122,28 @@ SET
learned = COALESCE(sqlc.narg('learned'), tg.learned) learned = COALESCE(sqlc.narg('learned'), tg.learned)
RETURNING *; RETURNING *;
-- name: StoreTGVersion :batchexec -- name: AddTalkgroupWithLearnedFlag :exec
INSERT INTO talkgroup_versions(time, created_by, INSERT INTO talkgroups (
system_id, system_id,
tgid, tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned learned
) SELECT NOW(), @submitter, ) VALUES(
tg.system_id, @system_id,
tg.tgid, @tgid,
tg.name, TRUE
tg.alpha_tag, );
tg.tg_group,
tg.frequency,
tg.metadata,
tg.tags,
tg.alert,
tg.alert_config,
tg.weight,
tg.learned
FROM talkgroups tg WHERE tg.system_id = @system_id AND tg.tgid = @tgid;
-- name: AddLearnedTalkgroup :one -- name: AddLearnedTalkgroup :one
INSERT INTO talkgroups( INSERT INTO talkgroups_learned(
system_id, system_id,
tgid, tgid,
learned,
name, name,
alpha_tag, alpha_tag,
tg_group tg_group
) VALUES ( ) VALUES (
@system_id, @system_id,
@tgid, @tgid,
TRUE,
sqlc.narg('name'), sqlc.narg('name'),
sqlc.narg('alpha_tag'), sqlc.narg('alpha_tag'),
sqlc.narg('tg_group') sqlc.narg('tg_group')
) RETURNING *; ) RETURNING id;
-- name: RestoreTalkgroupVersion :one
INSERT INTO talkgroups(
system_id,
tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned,
ignored
)
SELECT
system_id,
tgid,
name,
alpha_tag,
tg_group,
frequency,
metadata,
tags,
alert,
alert_config,
weight,
learned,
ignored
FROM talkgroup_versions tgv ON CONFLICT (system_id, tgid) DO UPDATE SET
name = excluded.name,
alpha_tag = excluded.alpha_tag,
tg_group = excluded.tg_group,
metadata = excluded.metadata,
tags = excluded.tags,
alert = excluded.alert,
alert_config = excluded.alert_config,
weight = excluded.weight,
learned = excluded.learner,
ignored = excluded.ignored
WHERE tgv.id = ANY(@version_ids)
RETURNING *;