Merge pull request 'Use Partman intervals, chart improvements' (#121) from partmanIntvl115 into trunk
Closes #115 Reviewed-on: #121
This commit is contained in:
commit
8fd0c85c1e
21 changed files with 331 additions and 156 deletions
|
@ -4,6 +4,7 @@ export interface CallRecord {
|
||||||
audioURL: string | null;
|
audioURL: string | null;
|
||||||
duration: number;
|
duration: number;
|
||||||
systemId: number;
|
systemId: number;
|
||||||
|
talkerAlias: string | null;
|
||||||
tgid: number;
|
tgid: number;
|
||||||
incidents: number; // in incident
|
incidents: number; // in incident
|
||||||
}
|
}
|
||||||
|
|
|
@ -168,6 +168,10 @@
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="talker">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Source</th>
|
||||||
|
<td mat-cell *matCellDef="let call" [innerHTML]="call | talker"></td>
|
||||||
|
</ng-container>
|
||||||
<ng-container matColumnDef="duration">
|
<ng-container matColumnDef="duration">
|
||||||
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
|
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
|
||||||
<td mat-cell *matCellDef="let call" class="duration">
|
<td mat-cell *matCellDef="let call" class="duration">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Component, ElementRef, inject, ViewChild } from '@angular/core';
|
import { Component, ElementRef, inject, ViewChild } from '@angular/core';
|
||||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatTable, MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import {
|
import {
|
||||||
MatPaginator,
|
MatPaginator,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
|
@ -19,6 +19,7 @@ import {
|
||||||
DatePipe,
|
DatePipe,
|
||||||
DownloadURLPipe,
|
DownloadURLPipe,
|
||||||
FixedPointPipe,
|
FixedPointPipe,
|
||||||
|
TalkerPipe,
|
||||||
TalkgroupPipe,
|
TalkgroupPipe,
|
||||||
TimePipe,
|
TimePipe,
|
||||||
} from './calls.service';
|
} from './calls.service';
|
||||||
|
@ -57,6 +58,7 @@ const reqPageSize = 200;
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
FixedPointPipe,
|
FixedPointPipe,
|
||||||
TalkgroupPipe,
|
TalkgroupPipe,
|
||||||
|
TalkerPipe,
|
||||||
TimePipe,
|
TimePipe,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
|
@ -95,6 +97,7 @@ export class CallsComponent {
|
||||||
'system',
|
'system',
|
||||||
'group',
|
'group',
|
||||||
'talkgroup',
|
'talkgroup',
|
||||||
|
'talker',
|
||||||
'duration',
|
'duration',
|
||||||
];
|
];
|
||||||
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
|
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
|
||||||
|
|
|
@ -27,6 +27,21 @@ export class DatePipe implements PipeTransform {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'talker',
|
||||||
|
standalone: true,
|
||||||
|
pure: true,
|
||||||
|
})
|
||||||
|
export class TalkerPipe implements PipeTransform {
|
||||||
|
transform(call: CallRecord, args?: any): string {
|
||||||
|
if (call.talkerAlias != null) {
|
||||||
|
return call.talkerAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'time',
|
name: 'time',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
<div class="chart"></div>
|
<div class="chart"></div>
|
||||||
|
<div class="spinner" *ngIf="loading"><mat-spinner></mat-spinner></div>
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import { Component, ElementRef, Input } from '@angular/core';
|
import { Component, ElementRef, Input } from '@angular/core';
|
||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import { CallsService } from '../calls/calls.service';
|
import { CallsService } from '../calls/calls.service';
|
||||||
import { CallStatsRecord } from '../calls';
|
import { Observable, switchMap } from 'rxjs';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { NgIf } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'chart',
|
selector: 'chart',
|
||||||
imports: [],
|
imports: [NgIf, MatProgressSpinnerModule],
|
||||||
templateUrl: './charts.component.html',
|
templateUrl: './charts.component.html',
|
||||||
styleUrl: './charts.component.scss',
|
styleUrl: './charts.component.scss',
|
||||||
})
|
})
|
||||||
export class ChartsComponent {
|
export class ChartsComponent {
|
||||||
@Input() interval!: string;
|
@Input() interval!: Observable<string>;
|
||||||
loading = true;
|
loading = true;
|
||||||
// I hate javascript so much
|
// I hate javascript so much
|
||||||
months = [
|
months = [
|
||||||
|
@ -32,8 +34,8 @@ export class ChartsComponent {
|
||||||
private callsSvc: CallsService,
|
private callsSvc: CallsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
dateFormat(d: Date): string {
|
dateFormat(d: Date, interval: string): string {
|
||||||
switch (this.interval) {
|
switch (interval) {
|
||||||
case 'month':
|
case 'month':
|
||||||
return `${this.months[d.getMonth()]} ${d.getFullYear()}`;
|
return `${this.months[d.getMonth()]} ${d.getFullYear()}`;
|
||||||
case 'day':
|
case 'day':
|
||||||
|
@ -45,70 +47,87 @@ export class ChartsComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.callsSvc.getCallStats(this.interval).subscribe((stats) => {
|
this.interval
|
||||||
let cMax = 0;
|
.pipe(
|
||||||
var cMin = 0;
|
switchMap((intv) => {
|
||||||
let data = stats.stats.map((rec) => {
|
d3.select(this.elementRef.nativeElement).select('.chart').html('');
|
||||||
if (cMin == 0 && rec.count > cMin) {
|
this.loading = true;
|
||||||
cMin = rec.count;
|
return this.callsSvc.getCallStats(intv);
|
||||||
}
|
}),
|
||||||
if (rec.count < cMin) {
|
)
|
||||||
cMin = rec.count;
|
.subscribe((stats) => {
|
||||||
}
|
let cMax = 0;
|
||||||
if (rec.count > cMax) {
|
var cMin = 0;
|
||||||
cMax = rec.count;
|
let data = stats.stats.map((rec) => {
|
||||||
}
|
if (cMin == 0 && rec.count > cMin) {
|
||||||
return { count: rec.count, time: this.dateFormat(new Date(rec.time)) };
|
cMin = rec.count;
|
||||||
});
|
}
|
||||||
// set the dimensions and margins of the graph
|
if (rec.count < cMin) {
|
||||||
var margin = { top: 30, right: 30, bottom: 70, left: 60 },
|
cMin = rec.count;
|
||||||
width = 460 - margin.left - margin.right,
|
}
|
||||||
height = 400 - margin.top - margin.bottom;
|
if (rec.count > cMax) {
|
||||||
const svg = d3
|
cMax = rec.count;
|
||||||
.select(this.elementRef.nativeElement)
|
}
|
||||||
.select('.chart')
|
return {
|
||||||
.append('svg')
|
count: rec.count,
|
||||||
.attr('width', width + margin.left + margin.right)
|
time: this.dateFormat(new Date(rec.time), stats.interval),
|
||||||
.attr('height', height + margin.top + margin.bottom)
|
};
|
||||||
.append('g')
|
|
||||||
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
|
||||||
// X axis
|
|
||||||
var x = d3
|
|
||||||
.scaleBand()
|
|
||||||
.range([0, width])
|
|
||||||
.domain(
|
|
||||||
data.map(function (d) {
|
|
||||||
return d.time;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.padding(0.2);
|
|
||||||
svg
|
|
||||||
.append('g')
|
|
||||||
.attr('transform', 'translate(0,' + height + ')')
|
|
||||||
.call(d3.axisBottom(x))
|
|
||||||
.selectAll('text')
|
|
||||||
.attr('transform', 'translate(-10,0)rotate(-45)')
|
|
||||||
.style('text-anchor', 'end');
|
|
||||||
|
|
||||||
// Add Y axis
|
|
||||||
var y = d3.scaleLinear().domain([0, cMax]).range([height, 0]);
|
|
||||||
svg.append('g').call(d3.axisLeft(y));
|
|
||||||
svg
|
|
||||||
.selectAll('mybar')
|
|
||||||
.data(data)
|
|
||||||
.enter()
|
|
||||||
.append('rect')
|
|
||||||
.attr('x', (d) => x(d.time)!)
|
|
||||||
.attr('y', function (d) {
|
|
||||||
return y(d.count);
|
|
||||||
})
|
|
||||||
.attr('width', x.bandwidth())
|
|
||||||
.attr('height', function (d) {
|
|
||||||
return height - y(d.count);
|
|
||||||
})
|
|
||||||
.attr('fill', function (d) {
|
|
||||||
return d3.interpolateTurbo((d.count - cMin) / (cMax - cMin));
|
|
||||||
});
|
});
|
||||||
});
|
// set the dimensions and margins of the graph
|
||||||
|
var margin = { top: 30, right: 30, bottom: 70, left: 60 },
|
||||||
|
width = 460 - margin.left - margin.right,
|
||||||
|
height = 400 - margin.top - margin.bottom;
|
||||||
|
// clear the old one
|
||||||
|
d3.select(this.elementRef.nativeElement).select('.chart').html('');
|
||||||
|
const svg = d3
|
||||||
|
.select(this.elementRef.nativeElement)
|
||||||
|
.select('.chart')
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', width + margin.left + margin.right)
|
||||||
|
.attr('height', height + margin.top + margin.bottom)
|
||||||
|
.append('g')
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
'translate(' + margin.left + ',' + margin.top + ')',
|
||||||
|
);
|
||||||
|
// X axis
|
||||||
|
var x = d3
|
||||||
|
.scaleBand()
|
||||||
|
.range([0, width])
|
||||||
|
.domain(
|
||||||
|
data.map(function (d) {
|
||||||
|
return d.time;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.padding(0.2);
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.attr('transform', 'translate(0,' + height + ')')
|
||||||
|
.call(d3.axisBottom(x))
|
||||||
|
.selectAll('text')
|
||||||
|
.attr('transform', 'translate(-10,0)rotate(-45)')
|
||||||
|
.style('text-anchor', 'end');
|
||||||
|
|
||||||
|
// Add Y axis
|
||||||
|
var y = d3.scaleLinear().domain([0, cMax]).range([height, 0]);
|
||||||
|
svg.append('g').call(d3.axisLeft(y));
|
||||||
|
svg
|
||||||
|
.selectAll('mybar')
|
||||||
|
.data(data)
|
||||||
|
.enter()
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', (d) => x(d.time)!)
|
||||||
|
.attr('y', function (d) {
|
||||||
|
return y(d.count);
|
||||||
|
})
|
||||||
|
.attr('width', x.bandwidth())
|
||||||
|
.attr('height', function (d) {
|
||||||
|
return height - y(d.count);
|
||||||
|
})
|
||||||
|
.attr('fill', function (d) {
|
||||||
|
return d3.interpolateTurbo((d.count - cMin) / (cMax - cMin));
|
||||||
|
});
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
<mat-card class="chart" appearance="outlined">
|
<mat-card class="chart" appearance="outlined">
|
||||||
<div class="chartTitle">Calls By Week</div>
|
<form [formGroup]="form">
|
||||||
<chart interval="week"></chart>
|
<mat-form-field>
|
||||||
|
<label>Calls By</label>
|
||||||
|
<mat-select formControlName="callsPer">
|
||||||
|
<mat-option value="hour">Hour</mat-option>
|
||||||
|
<mat-option value="day">Day</mat-option>
|
||||||
|
<mat-option value="week">Week</mat-option>
|
||||||
|
<mat-option value="month">Month</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</form>
|
||||||
|
<chart #chart [interval]="callsOb"></chart>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
mat-card.chart {
|
mat-card.chart {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 400px;
|
height: 500px;
|
||||||
margin: 30px 30px 40px 40px;
|
margin: 30px 30px 40px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,50 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component, computed, signal, ViewChild } from '@angular/core';
|
||||||
import { ChartsComponent } from '../charts/charts.component';
|
import { ChartsComponent } from '../charts/charts.component';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import {
|
||||||
|
debounceTime,
|
||||||
|
filter,
|
||||||
|
Observable,
|
||||||
|
startWith,
|
||||||
|
switchAll,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [ChartsComponent, MatCardModule],
|
imports: [
|
||||||
|
ChartsComponent,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatInputModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrl: './home.component.scss',
|
styleUrl: './home.component.scss',
|
||||||
})
|
})
|
||||||
export class HomeComponent {}
|
export class HomeComponent {
|
||||||
|
form = new FormGroup({
|
||||||
|
callsPer: new FormControl(),
|
||||||
|
});
|
||||||
|
callsOb!: Observable<string>;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.form.controls['callsPer'].setValue('week');
|
||||||
|
this.callsOb = this.form.controls['callsPer'].valueChanges.pipe(
|
||||||
|
startWith('week'),
|
||||||
|
debounceTime(300),
|
||||||
|
filter((val) => val !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
84
internal/common/intervals.go
Normal file
84
internal/common/intervals.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DaysInWeek = 7
|
||||||
|
MonthsInQuarter = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeBounder interface {
|
||||||
|
GetDailyBounds(date time.Time) (lowerBound, upperBound time.Time)
|
||||||
|
GetWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time)
|
||||||
|
GetMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time)
|
||||||
|
GetQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time)
|
||||||
|
GetYearlyBounds(date time.Time) (lowerBound, upperBound time.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tbOpt func(*timeBounder)
|
||||||
|
|
||||||
|
func WithLocation(l *time.Location) tbOpt {
|
||||||
|
return func(tb *timeBounder) {
|
||||||
|
tb.loc = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimeBounder(opts ...tbOpt) timeBounder {
|
||||||
|
tb := timeBounder{}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&tb)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tb.loc == nil {
|
||||||
|
tb.loc = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
return tb
|
||||||
|
}
|
||||||
|
|
||||||
|
type timeBounder struct {
|
||||||
|
loc *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb timeBounder) GetDailyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
||||||
|
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tb.loc)
|
||||||
|
upperBound = lowerBound.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb timeBounder) GetWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
||||||
|
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tb.loc).AddDate(0, 0, -int(date.Weekday()-time.Monday))
|
||||||
|
upperBound = lowerBound.AddDate(0, 0, DaysInWeek)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb timeBounder) GetMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
||||||
|
lowerBound = time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, tb.loc)
|
||||||
|
upperBound = lowerBound.AddDate(0, 1, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *timeBounder) GetQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
||||||
|
year, _, _ := date.Date()
|
||||||
|
|
||||||
|
quarter := (int(date.Month()) - 1) / MonthsInQuarter
|
||||||
|
firstMonthOfTheQuarter := time.Month(quarter*MonthsInQuarter + 1)
|
||||||
|
|
||||||
|
lowerBound = time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, tb.loc)
|
||||||
|
upperBound = lowerBound.AddDate(0, MonthsInQuarter, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb timeBounder) GetYearlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
||||||
|
lowerBound = time.Date(date.Year(), 1, 1, 0, 0, 0, 0, tb.loc)
|
||||||
|
upperBound = lowerBound.AddDate(1, 0, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
|
@ -182,7 +182,7 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
|
||||||
origScore := s.Score
|
origScore := s.Score
|
||||||
tgr, err := as.tgCache.TG(ctx, s.ID)
|
tgr, err := as.tgCache.TG(ctx, s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("alerting eval tg get")
|
log.Debug().Str("tg", s.ID.String()).Err(err).Msg("alerting eval tg get")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,17 @@ func (c *Call) GetResourceName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Call) String() string {
|
func (c *Call) String() string {
|
||||||
return fmt.Sprintf("%s to %d from %d", c.AudioName, c.Talkgroup, c.Source)
|
var from string
|
||||||
|
switch {
|
||||||
|
case c.Source != 0 && c.TalkerAlias != nil:
|
||||||
|
from = fmt.Sprintf(" from %s (%d)", *c.TalkerAlias, c.Source)
|
||||||
|
case c.Source != 0:
|
||||||
|
from = fmt.Sprintf(" from %d", c.Source)
|
||||||
|
case c.TalkerAlias != nil:
|
||||||
|
from = fmt.Sprintf(" from %s", *c.TalkerAlias)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s to %d%s", c.AudioName, c.Talkgroup, from)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Call) ShouldStore() bool {
|
func (c *Call) ShouldStore() bool {
|
||||||
|
|
|
@ -204,6 +204,7 @@ type CallsParams struct {
|
||||||
TagsNot []string `json:"tagsNot"`
|
TagsNot []string `json:"tagsNot"`
|
||||||
TGFilter *string `json:"tgFilter"`
|
TGFilter *string `json:"tgFilter"`
|
||||||
AtLeastSeconds *float32 `json:"atLeastSeconds"`
|
AtLeastSeconds *float32 `json:"atLeastSeconds"`
|
||||||
|
UnknownTG bool `json:"unknownTG"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
|
func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
|
||||||
|
@ -224,6 +225,7 @@ func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []databa
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
Direction: p.Direction.DirString(common.DirAsc),
|
Direction: p.Direction.DirString(common.DirAsc),
|
||||||
TGFilter: p.TGFilter,
|
TGFilter: p.TGFilter,
|
||||||
|
UnknownTG: p.UnknownTG,
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.AtLeastSeconds != nil {
|
if p.AtLeastSeconds != nil {
|
||||||
|
@ -245,6 +247,7 @@ func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []databa
|
||||||
TagsNot: par.TagsNot,
|
TagsNot: par.TagsNot,
|
||||||
TGFilter: par.TGFilter,
|
TGFilter: par.TGFilter,
|
||||||
LongerThan: par.LongerThan,
|
LongerThan: par.LongerThan,
|
||||||
|
UnknownTG: par.UnknownTG,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -319,6 +319,9 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN
|
||||||
) ELSE TRUE END) AND
|
) ELSE TRUE END) AND
|
||||||
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
||||||
c.duration > $6
|
c.duration > $6
|
||||||
|
) ELSE TRUE END) AND
|
||||||
|
(CASE WHEN $7::BOOLEAN = TRUE THEN (
|
||||||
|
tgs.tgid IS NULL
|
||||||
) ELSE TRUE END)
|
) ELSE TRUE END)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -329,6 +332,7 @@ type ListCallsCountParams struct {
|
||||||
TagsNot []string `json:"tagsNot"`
|
TagsNot []string `json:"tagsNot"`
|
||||||
TGFilter *string `json:"tgFilter"`
|
TGFilter *string `json:"tgFilter"`
|
||||||
LongerThan pgtype.Numeric `json:"longerThan"`
|
LongerThan pgtype.Numeric `json:"longerThan"`
|
||||||
|
UnknownTG bool `json:"unknownTg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
|
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
|
||||||
|
@ -339,6 +343,7 @@ func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams)
|
||||||
arg.TagsNot,
|
arg.TagsNot,
|
||||||
arg.TGFilter,
|
arg.TGFilter,
|
||||||
arg.LongerThan,
|
arg.LongerThan,
|
||||||
|
arg.UnknownTG,
|
||||||
)
|
)
|
||||||
var count int64
|
var count int64
|
||||||
err := row.Scan(&count)
|
err := row.Scan(&count)
|
||||||
|
@ -374,13 +379,16 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN
|
||||||
) ELSE TRUE END) AND
|
) ELSE TRUE END) AND
|
||||||
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
|
||||||
c.duration > $6
|
c.duration > $6
|
||||||
|
) ELSE TRUE END) AND
|
||||||
|
(CASE WHEN $7::BOOLEAN = TRUE THEN (
|
||||||
|
tgs.tgid IS NULL
|
||||||
) ELSE TRUE END)
|
) ELSE TRUE END)
|
||||||
GROUP BY c.id, c.call_date
|
GROUP BY c.id, c.call_date
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN $7::TEXT = 'asc' THEN c.call_date END ASC,
|
CASE WHEN $8::TEXT = 'asc' THEN c.call_date END ASC,
|
||||||
CASE WHEN $7 = 'desc' THEN c.call_date END DESC
|
CASE WHEN $8 = 'desc' THEN c.call_date END DESC
|
||||||
OFFSET $8 ROWS
|
OFFSET $9 ROWS
|
||||||
FETCH NEXT $9 ROWS ONLY
|
FETCH NEXT $10 ROWS ONLY
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListCallsPParams struct {
|
type ListCallsPParams struct {
|
||||||
|
@ -390,6 +398,7 @@ type ListCallsPParams struct {
|
||||||
TagsNot []string `json:"tagsNot"`
|
TagsNot []string `json:"tagsNot"`
|
||||||
TGFilter *string `json:"tgFilter"`
|
TGFilter *string `json:"tgFilter"`
|
||||||
LongerThan pgtype.Numeric `json:"longerThan"`
|
LongerThan pgtype.Numeric `json:"longerThan"`
|
||||||
|
UnknownTG bool `json:"unknownTg"`
|
||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
Offset int32 `json:"offset"`
|
Offset int32 `json:"offset"`
|
||||||
PerPage int32 `json:"perPage"`
|
PerPage int32 `json:"perPage"`
|
||||||
|
@ -413,6 +422,7 @@ func (q *Queries) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListC
|
||||||
arg.TagsNot,
|
arg.TagsNot,
|
||||||
arg.TGFilter,
|
arg.TGFilter,
|
||||||
arg.LongerThan,
|
arg.LongerThan,
|
||||||
|
arg.UnknownTG,
|
||||||
arg.Direction,
|
arg.Direction,
|
||||||
arg.Offset,
|
arg.Offset,
|
||||||
arg.PerPage,
|
arg.PerPage,
|
||||||
|
|
|
@ -3,66 +3,23 @@ package partman
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dynatron.me/x/stillbox/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
daysInWeek = 7
|
|
||||||
monthsInQuarter = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
func getDailyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
|
||||||
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
upperBound = lowerBound.AddDate(0, 0, 1)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
|
||||||
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -int(date.Weekday()-time.Monday))
|
|
||||||
upperBound = lowerBound.AddDate(0, 0, daysInWeek)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
|
||||||
lowerBound = time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
upperBound = lowerBound.AddDate(0, 1, 0)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
|
||||||
year, _, _ := date.Date()
|
|
||||||
|
|
||||||
quarter := (int(date.Month()) - 1) / monthsInQuarter
|
|
||||||
firstMonthOfTheQuarter := time.Month(quarter*monthsInQuarter + 1)
|
|
||||||
|
|
||||||
lowerBound = time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
upperBound = lowerBound.AddDate(0, monthsInQuarter, 0)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getYearlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
|
|
||||||
lowerBound = time.Date(date.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
upperBound = lowerBound.AddDate(1, 0, 0)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Partition) Next(i int) Partition {
|
func (p Partition) Next(i int) Partition {
|
||||||
var t time.Time
|
var t time.Time
|
||||||
switch p.Interval {
|
switch p.Interval {
|
||||||
case Daily:
|
case Daily:
|
||||||
t = p.Time.AddDate(0, 0, i)
|
t = p.Time.AddDate(0, 0, i)
|
||||||
case Weekly:
|
case Weekly:
|
||||||
t = p.Time.AddDate(0, 0, i*daysInWeek)
|
t = p.Time.AddDate(0, 0, i*common.DaysInWeek)
|
||||||
case Monthly:
|
case Monthly:
|
||||||
year, month, _ := p.Time.Date()
|
year, month, _ := p.Time.Date()
|
||||||
|
|
||||||
t = time.Date(year, month+time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
|
t = time.Date(year, month+time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
|
||||||
case Quarterly:
|
case Quarterly:
|
||||||
t = p.Time.AddDate(0, i*monthsInQuarter, 0)
|
t = p.Time.AddDate(0, i*common.MonthsInQuarter, 0)
|
||||||
case Yearly:
|
case Yearly:
|
||||||
year, _, _ := p.Time.Date()
|
year, _, _ := p.Time.Date()
|
||||||
|
|
||||||
|
@ -125,13 +82,13 @@ func (p Partition) Prev(i int) Partition {
|
||||||
case Daily:
|
case Daily:
|
||||||
t = p.Time.AddDate(0, 0, -i)
|
t = p.Time.AddDate(0, 0, -i)
|
||||||
case Weekly:
|
case Weekly:
|
||||||
t = p.Time.AddDate(0, 0, -i*daysInWeek)
|
t = p.Time.AddDate(0, 0, -i*common.DaysInWeek)
|
||||||
case Monthly:
|
case Monthly:
|
||||||
year, month, _ := p.Time.Date()
|
year, month, _ := p.Time.Date()
|
||||||
|
|
||||||
t = time.Date(year, month-time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
|
t = time.Date(year, month-time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
|
||||||
case Quarterly:
|
case Quarterly:
|
||||||
t = p.Time.AddDate(0, -i*monthsInQuarter, 0)
|
t = p.Time.AddDate(0, -i*common.MonthsInQuarter, 0)
|
||||||
case Yearly:
|
case Yearly:
|
||||||
year, _, _ := p.Time.Date()
|
year, _, _ := p.Time.Date()
|
||||||
|
|
||||||
|
@ -150,3 +107,21 @@ func (p Partition) Prev(i int) Partition {
|
||||||
return pp
|
return pp
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p Partition) Range() (time.Time, time.Time) {
|
||||||
|
b := common.NewTimeBounder()
|
||||||
|
switch p.Interval {
|
||||||
|
case Daily:
|
||||||
|
return b.GetDailyBounds(p.Time)
|
||||||
|
case Weekly:
|
||||||
|
return b.GetWeeklyBounds(p.Time)
|
||||||
|
case Monthly:
|
||||||
|
return b.GetMonthlyBounds(p.Time)
|
||||||
|
case Quarterly:
|
||||||
|
return b.GetQuarterlyBounds(p.Time)
|
||||||
|
case Yearly:
|
||||||
|
return b.GetYearlyBounds(p.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("unknown interval!")
|
||||||
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dynatron.me/x/stillbox/internal/common"
|
||||||
"dynatron.me/x/stillbox/internal/isoweek"
|
"dynatron.me/x/stillbox/internal/isoweek"
|
||||||
"dynatron.me/x/stillbox/pkg/config"
|
"dynatron.me/x/stillbox/pkg/config"
|
||||||
"dynatron.me/x/stillbox/pkg/database"
|
"dynatron.me/x/stillbox/pkg/database"
|
||||||
|
@ -325,23 +326,6 @@ func (pm *partman) Check(ctx context.Context, now time.Time) error {
|
||||||
}, pgx.TxOptions{})
|
}, pgx.TxOptions{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Partition) Range() (time.Time, time.Time) {
|
|
||||||
switch p.Interval {
|
|
||||||
case Daily:
|
|
||||||
return getDailyBounds(p.Time)
|
|
||||||
case Weekly:
|
|
||||||
return getWeeklyBounds(p.Time)
|
|
||||||
case Monthly:
|
|
||||||
return getMonthlyBounds(p.Time)
|
|
||||||
case Quarterly:
|
|
||||||
return getQuarterlyBounds(p.Time)
|
|
||||||
case Yearly:
|
|
||||||
return getYearlyBounds(p.Time)
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("unknown interval!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Partition) PartitionName() string {
|
func (p Partition) PartitionName() string {
|
||||||
return p.Name
|
return p.Name
|
||||||
}
|
}
|
||||||
|
@ -423,7 +407,7 @@ func (pm *partman) verifyPartName(pr database.PartitionResult) (p Partition, err
|
||||||
if quarterNum > 4 {
|
if quarterNum > 4 {
|
||||||
return p, PartitionError(pn, errors.New("invalid quarter"))
|
return p, PartitionError(pn, errors.New("invalid quarter"))
|
||||||
}
|
}
|
||||||
firstMonthOfTheQuarter := time.Month((quarterNum-1)*monthsInQuarter + 1)
|
firstMonthOfTheQuarter := time.Month((quarterNum-1)*common.MonthsInQuarter + 1)
|
||||||
parsed := time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, time.UTC)
|
parsed := time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, time.UTC)
|
||||||
if parsed != p.Time {
|
if parsed != p.Time {
|
||||||
return p, PartitionError(pn, ParsedIntvlErr{parsed: parsed, start: p.Time})
|
return p, PartitionError(pn, ParsedIntvlErr{parsed: parsed, start: p.Time})
|
||||||
|
|
|
@ -20,7 +20,7 @@ WHERE
|
||||||
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date >= $2 ELSE TRUE END AND
|
c.call_date >= $2 ELSE TRUE END AND
|
||||||
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date <= $3 ELSE TRUE END
|
c.call_date < $3 ELSE TRUE END
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
`
|
`
|
||||||
|
@ -61,7 +61,7 @@ WHERE
|
||||||
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date >= $2 ELSE TRUE END AND
|
c.call_date >= $2 ELSE TRUE END AND
|
||||||
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date <= $3 ELSE TRUE END
|
c.call_date < $3 ELSE TRUE END
|
||||||
GROUP BY 2, 3, 4
|
GROUP BY 2, 3, 4
|
||||||
ORDER BY 4 DESC
|
ORDER BY 4 DESC
|
||||||
`
|
`
|
||||||
|
|
|
@ -229,8 +229,13 @@ func (ia *incidentsAPI) getCallsM3U(id ID, w http.ResponseWriter, r *http.Reques
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var from string
|
var from string
|
||||||
if c.Source != 0 {
|
switch {
|
||||||
|
case c.Source != 0 && c.TalkerAlias != nil:
|
||||||
|
from = fmt.Sprintf(" from %s (%d)", *c.TalkerAlias, c.Source)
|
||||||
|
case c.Source != 0:
|
||||||
from = fmt.Sprintf(" from %d", c.Source)
|
from = fmt.Sprintf(" from %d", c.Source)
|
||||||
|
case c.TalkerAlias != nil:
|
||||||
|
from = fmt.Sprintf(" from %s", *c.TalkerAlias)
|
||||||
}
|
}
|
||||||
|
|
||||||
callUrl.Path = urlRoot + c.ID.String()
|
callUrl.Path = urlRoot + c.ID.String()
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dynatron.me/x/stillbox/internal/cache"
|
"dynatron.me/x/stillbox/internal/cache"
|
||||||
|
"dynatron.me/x/stillbox/internal/common"
|
||||||
"dynatron.me/x/stillbox/internal/jsontypes"
|
"dynatron.me/x/stillbox/internal/jsontypes"
|
||||||
"dynatron.me/x/stillbox/pkg/calls"
|
"dynatron.me/x/stillbox/pkg/calls"
|
||||||
"dynatron.me/x/stillbox/pkg/calls/callstore"
|
"dynatron.me/x/stillbox/pkg/calls/callstore"
|
||||||
|
@ -56,20 +57,25 @@ func (s *stats) GetCallStats(ctx context.Context, interval calls.StatsInterval)
|
||||||
|
|
||||||
var start time.Time
|
var start time.Time
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
end := now
|
||||||
|
bnd := common.NewTimeBounder(common.WithLocation(now.Location()))
|
||||||
|
|
||||||
switch interval {
|
switch interval {
|
||||||
case calls.IntervalHour:
|
case calls.IntervalHour:
|
||||||
start = now.Add(-24 * time.Hour) // one day
|
start = now.Add(-24 * time.Hour) // one day
|
||||||
case calls.IntervalDay:
|
case calls.IntervalDay:
|
||||||
start = now.Add(-7 * 24 * time.Hour) // one week
|
start = now.Add(-7 * 24 * time.Hour) // one week
|
||||||
case calls.IntervalWeek:
|
case calls.IntervalWeek:
|
||||||
start = now.Add(-30 * 24 * time.Hour) // one month
|
start, end = bnd.GetMonthlyBounds(now)
|
||||||
|
start, _ = bnd.GetWeeklyBounds(start)
|
||||||
|
_, end = bnd.GetWeeklyBounds(end)
|
||||||
case calls.IntervalMonth:
|
case calls.IntervalMonth:
|
||||||
start = now.Add(-365 * 24 * time.Hour) // one year
|
start = now.Add(-365 * 24 * time.Hour) // one year
|
||||||
default:
|
default:
|
||||||
return nil, calls.ErrInvalidInterval
|
return nil, calls.ErrInvalidInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(now))
|
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(end))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,6 +127,9 @@ CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
|
||||||
) ELSE TRUE END) AND
|
) ELSE TRUE END) AND
|
||||||
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
||||||
c.duration > @longer_than
|
c.duration > @longer_than
|
||||||
|
) ELSE TRUE END) AND
|
||||||
|
(CASE WHEN @unknown_tg::BOOLEAN = TRUE THEN (
|
||||||
|
tgs.tgid IS NULL
|
||||||
) ELSE TRUE END)
|
) ELSE TRUE END)
|
||||||
GROUP BY c.id, c.call_date
|
GROUP BY c.id, c.call_date
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
@ -157,6 +160,9 @@ CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
|
||||||
) ELSE TRUE END) AND
|
) ELSE TRUE END) AND
|
||||||
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
|
||||||
c.duration > @longer_than
|
c.duration > @longer_than
|
||||||
|
) ELSE TRUE END) AND
|
||||||
|
(CASE WHEN @unknown_tg::BOOLEAN = TRUE THEN (
|
||||||
|
tgs.tgid IS NULL
|
||||||
) ELSE TRUE END)
|
) ELSE TRUE END)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ WHERE
|
||||||
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date >= @start ELSE TRUE END AND
|
c.call_date >= @start ELSE TRUE END AND
|
||||||
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date <= sqlc.narg('end') ELSE TRUE END
|
c.call_date < sqlc.narg('end') ELSE TRUE END
|
||||||
GROUP BY 2, 3, 4
|
GROUP BY 2, 3, 4
|
||||||
ORDER BY 4 DESC;
|
ORDER BY 4 DESC;
|
||||||
|
|
||||||
|
@ -22,6 +22,6 @@ WHERE
|
||||||
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date >= @start ELSE TRUE END AND
|
c.call_date >= @start ELSE TRUE END AND
|
||||||
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
|
||||||
c.call_date <= sqlc.narg('end') ELSE TRUE END
|
c.call_date < sqlc.narg('end') ELSE TRUE END
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date DESC;
|
ORDER BY date DESC;
|
||||||
|
|
Loading…
Add table
Reference in a new issue