Compare commits

..

9 commits

96 changed files with 597 additions and 16757 deletions

View file

@ -1,17 +0,0 @@
# 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

View file

@ -1,42 +0,0 @@
# 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

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

View file

@ -1 +0,0 @@
{}

View file

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

View file

@ -1,20 +0,0 @@
{
// 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"
}
]
}

View file

@ -1,42 +0,0 @@
{
// 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"
}
}
}
}
]
}

View file

@ -1,27 +0,0 @@
# 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.

View file

@ -1,90 +0,0 @@
{
"$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
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
{
"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

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

View file

@ -1,22 +0,0 @@
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

@ -1,10 +0,0 @@
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

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

View file

@ -1,302 +0,0 @@
<!-- ========== 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

@ -1,29 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,17 +0,0 @@
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

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

View file

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

View file

@ -1,22 +0,0 @@
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

@ -1,10 +0,0 @@
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

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

View file

@ -1,22 +0,0 @@
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

@ -1,10 +0,0 @@
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

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

View file

@ -1,22 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,36 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,45 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,77 +0,0 @@
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

@ -1,33 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,68 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,31 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,13 +0,0 @@
<!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>

View file

@ -1,7 +0,0 @@
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

@ -1,9 +0,0 @@
/* 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

@ -1,18 +0,0 @@
/** @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

@ -1,11 +0,0 @@
/* 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

@ -1,30 +0,0 @@
/* 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

@ -1,10 +0,0 @@
/* 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 . }})"
# bodyTemplate: |
# {{ range . -}}
# {{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
# {{ .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)
#
# {{ end -}}
config:

View file

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

View file

@ -3,6 +3,7 @@ package alerting
import (
"context"
"fmt"
"math/rand"
"net/http"
"sort"
"sync"
@ -14,7 +15,8 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/sinks"
talkgroups "dynatron.me/x/stillbox/pkg/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/trending"
@ -50,7 +52,7 @@ type alerter struct {
alertCache map[talkgroups.ID]alert.Alert
renotify time.Duration
notifier notify.Notifier
tgCache talkgroups.Store
tgCache tgstore.Store
}
type offsetClock time.Duration
@ -85,7 +87,7 @@ func WithNotifier(n notify.Notifier) AlertOption {
}
// New creates a new Alerter using the provided configuration.
func New(cfg config.Alerting, tgCache talkgroups.Store, opts ...AlertOption) Alerter {
func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerter {
if !cfg.Enable {
return &noopAlerter{}
}
@ -168,7 +170,7 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
if s.Score > as.cfg.AlertThreshold || testMode {
if old, inCache := as.alertCache[s.ID]; !inCache || now.Sub(old.Timestamp) > as.renotify {
s.Score *= as.tgCache.Weight(ctx, s.ID, now)
a, err := alert.Make(ctx, as.tgCache, s, origScore)
a, err := alert.Make(ctx, s, origScore)
if err != nil {
return nil, fmt.Errorf("makeAlert: %w", err)
}
@ -200,6 +202,14 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
log.Error().Err(err).Msg("test notification eval")
@ -207,6 +217,8 @@ func (as *alerter) testNotifyHandler(w http.ResponseWriter, r *http.Request) {
return
}
alerts = append(alerts, a)
err = as.notifier.Send(ctx, alerts)
if err != nil {
log.Error().Err(err).Msg("test notification send")

View file

@ -13,6 +13,7 @@ import (
"dynatron.me/x/stillbox/internal/trending"
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/rs/zerolog/log"
)
@ -59,7 +60,7 @@ func (s *Simulation) stepClock(t time.Time) {
// 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) {
now := time.Now()
tgc := talkgroups.NewCache()
tgc := tgstore.NewCache()
s.Enable = true
s.alerter = New(s.Alerting, tgc, WithClock(&s.clock)).(*alerter)

View file

@ -40,6 +40,23 @@ type jwtAuth 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) {
// TODO: check IP against ACL, or conf.Public, and against map of routes
tok, cl, err := jwtauth.FromContext(r.Context())

View file

@ -1,13 +1,11 @@
package calls
import (
"context"
"fmt"
"time"
"dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
@ -113,21 +111,6 @@ 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) {
var td time.Duration

View file

@ -18,6 +18,83 @@ var (
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
INSERT INTO talkgroups AS tg (
system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
@ -47,7 +124,7 @@ SET
alert_config = COALESCE($10, tg.alert_config),
weight = COALESCE($11, tg.weight),
learned = COALESCE($12, tg.learned)
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned, ignored
`
type UpsertTalkgroupBatchResults struct {
@ -119,6 +196,7 @@ func (b *UpsertTalkgroupBatchResults) QueryRow(f func(int, Talkgroup, error)) {
&i.AlertConfig,
&i.Weight,
&i.Learned,
&i.Ignored,
)
if f != nil {
f(t, i, err)

View file

@ -21,8 +21,12 @@ func (g Talkgroup) GetSystem() System { return S
func (g Talkgroup) GetLearned() bool { return false }
func (g Talkgroup) String() string {
return g.StringTag(true)
}
func (g Talkgroup) StringTag(withTag bool) string {
switch {
case g.AlphaTag != nil:
case withTag && g.AlphaTag != nil:
return *g.AlphaTag
case g.Name != nil && g.TGGroup != nil:
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
func (_m *Store) AddLearnedTalkgroup(ctx context.Context, arg database.AddLearnedTalkgroupParams) (int, error) {
func (_m *Store) AddLearnedTalkgroup(ctx context.Context, arg database.AddLearnedTalkgroupParams) (database.Talkgroup, error) {
ret := _m.Called(ctx, arg)
if len(ret) == 0 {
panic("no return value specified for AddLearnedTalkgroup")
}
var r0 int
var r0 database.Talkgroup
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) (int, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) (database.Talkgroup, error)); ok {
return rf(ctx, arg)
}
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) int); ok {
if rf, ok := ret.Get(0).(func(context.Context, database.AddLearnedTalkgroupParams) database.Talkgroup); ok {
r0 = rf(ctx, arg)
} else {
r0 = ret.Get(0).(int)
r0 = ret.Get(0).(database.Talkgroup)
}
if rf, ok := ret.Get(1).(func(context.Context, database.AddLearnedTalkgroupParams) error); ok {
@ -169,60 +169,12 @@ func (_c *Store_AddLearnedTalkgroup_Call) Run(run func(ctx context.Context, arg
return _c
}
func (_c *Store_AddLearnedTalkgroup_Call) Return(_a0 int, _a1 error) *Store_AddLearnedTalkgroup_Call {
func (_c *Store_AddLearnedTalkgroup_Call) Return(_a0 database.Talkgroup, _a1 error) *Store_AddLearnedTalkgroup_Call {
_c.Call.Return(_a0, _a1)
return _c
}
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 {
func (_c *Store_AddLearnedTalkgroup_Call) RunAndReturn(run func(context.Context, database.AddLearnedTalkgroupParams) (database.Talkgroup, error)) *Store_AddLearnedTalkgroup_Call {
_c.Call.Return(run)
return _c
}
@ -1569,6 +1521,63 @@ func (_c *Store_InTx_Call) RunAndReturn(run func(context.Context, func(database.
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
func (_m *Store) SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error {
ret := _m.Called(ctx, iD, transcript)
@ -1666,6 +1675,55 @@ func (_c *Store_SetTalkgroupTags_Call) RunAndReturn(run func(context.Context, []
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
func (_m *Store) UpdatePassword(ctx context.Context, username string, password string) error {
ret := _m.Called(ctx, username, password)

View file

@ -96,16 +96,26 @@ type Talkgroup struct {
AlertConfig rules.AlertRules `json:"alert_config"`
Weight float32 `json:"weight"`
Learned bool `json:"learned"`
Ignored bool `json:"ignored"`
}
type TalkgroupsLearned struct {
ID int `json:"id"`
SystemID int `json:"system_id"`
TGID int `json:"tgid"`
Name string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
Ignored *bool `json:"ignored"`
type TalkgroupVersion struct {
ID int `json:"id"`
Time pgtype.Timestamptz `json:"time"`
CreatedBy *int32 `json:"created_by"`
SystemID *int32 `json:"system_id"`
TGID *int32 `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata []byte `json:"metadata"`
Tags []string `json:"tags"`
Alert *bool `json:"alert"`
AlertConfig []byte `json:"alert_config"`
Weight *float32 `json:"weight"`
Learned *bool `json:"learned"`
Ignored *bool `json:"ignored"`
}
type User struct {

View file

@ -14,8 +14,7 @@ import (
type Querier interface {
AddAlert(ctx context.Context, arg AddAlertParams) error
AddCall(ctx context.Context, arg AddCallParams) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (int, error)
AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error)
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteAPIKey(ctx context.Context, apiKey string) error
@ -35,8 +34,10 @@ type Querier interface {
GetUserByUID(ctx context.Context, id int) (User, error)
GetUserByUsername(ctx context.Context, username string) (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
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
UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams) (Talkgroup, error)
UpsertTalkgroup(ctx context.Context, arg []UpsertTalkgroupParams) *UpsertTalkgroupBatchResults

View file

@ -41,20 +41,11 @@ func (t *TGTuples) Append(sys, tg uint32) {
// Below queries are here because sqlc refuses to parse unnest(x, y)
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.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
FROM talkgroups tg
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)
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);`
WHERE tg.learned IS NOT TRUE;`
type GetTalkgroupsRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
@ -86,6 +77,7 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
&i.System.ID,
&i.System.Name,
&i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
); err != nil {
return nil, err
}
@ -97,7 +89,7 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
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, 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, tg.learned, tg.ignored, sys.id, sys.name FROM talkgroups tg
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)
WHERE tg.learned IS NOT TRUE;`
@ -124,6 +116,8 @@ func (q *Queries) GetTalkgroupsBySysTGID(ctx context.Context, ids TGTuples) ([]G
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
&i.Talkgroup.Ignored,
&i.System.ID,
&i.System.Name,
); err != nil {

View file

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

View file

@ -1,64 +0,0 @@
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,6 +6,7 @@ import (
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/structpb"
@ -59,9 +60,9 @@ func (c *client) SendError(cmd *pb.Command, err error) {
}
func (c *client) Talkgroup(ctx context.Context, tg *pb.Talkgroup) error {
tgi, err := talkgroups.StoreFrom(ctx).TG(ctx, talkgroups.TG(tg.System, tg.Talkgroup))
tgi, err := tgstore.FromCtx(ctx).TG(ctx, talkgroups.TG(tg.System, tg.Talkgroup))
if err != nil {
if err != talkgroups.ErrNotFound {
if err != tgstore.ErrNotFound {
log.Error().Err(err).Int32("sys", tg.System).Int32("tg", tg.Talkgroup).Msg("get talkgroup fail")
}
return err

View file

@ -52,7 +52,7 @@ var alertFm = template.FuncMap{
const (
defaultBodyTemplStr = `{{ range . -}}
{{ .TGName }} is active with a score of {{ f .Score.Score 4 }}! ({{ f .Score.RecentCount 0 }}/{{ .Score.Count }} recent calls)
{{ .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)
{{ end -}}`
defaultSubjectTemplStr = `Stillbox Alert ({{ highest . }})`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
)
type ImportSource string
@ -66,9 +67,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) {
sc := bufio.NewScanner(r)
tgs := make([]talkgroups.Talkgroup, 0, 8)
sysn, has := talkgroups.StoreFrom(ctx).SystemName(ctx, sys)
sysn, has := tgstore.FromCtx(ctx).SystemName(ctx, sys)
if !has {
return nil, talkgroups.ErrNoSuchSystem
return nil, tgstore.ErrNoSuchSystem
}
var groupName string
@ -119,6 +120,7 @@ func (rr *radioReferenceImporter) importTalkgroups(ctx context.Context, sys int,
Metadata: metadata,
Tags: tags,
Weight: 1.0,
Alert: true,
},
System: database.System{
ID: sys,

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -29,47 +29,20 @@ SELECT
sqlc.embed(tg), sqlc.embed(sys)
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
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;
WHERE (tg.system_id, tg.tgid) = (@system_id, @tgid);
-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
sqlc.embed(tg), sqlc.embed(sys)
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
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;
WHERE tg.system_id = @system;
-- name: GetTalkgroupsWithLearned :many
SELECT
sqlc.embed(tg), sqlc.embed(sys)
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;
-- name: GetSystemName :one
@ -122,28 +95,92 @@ SET
learned = COALESCE(sqlc.narg('learned'), tg.learned)
RETURNING *;
-- name: AddTalkgroupWithLearnedFlag :exec
INSERT INTO talkgroups (
-- 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
) VALUES(
@system_id,
@tgid,
TRUE
);
) SELECT NOW(), @submitter,
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 = @system_id AND tg.tgid = @tgid;
-- name: AddLearnedTalkgroup :one
INSERT INTO talkgroups_learned(
INSERT INTO talkgroups(
system_id,
tgid,
learned,
name,
alpha_tag,
tg_group
) VALUES (
@system_id,
@tgid,
TRUE,
sqlc.narg('name'),
sqlc.narg('alpha_tag'),
sqlc.narg('tg_group')
) RETURNING id;
) RETURNING *;
-- 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 *;