Merge pull request 'Call statistics' (#112) from callStats into trunk

Reviewed-on: #112
This commit is contained in:
Daniel Ponte 2025-02-17 14:09:39 -05:00
commit 02d0befd75
42 changed files with 1557 additions and 53 deletions

View file

@ -19,6 +19,8 @@
"@angular/platform-browser-dynamic": "^19.0.5", "@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5", "@angular/router": "^19.0.5",
"@angular/service-worker": "^19.0.5", "@angular/service-worker": "^19.0.5",
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"sass": "^1.82.0", "sass": "^1.82.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
@ -4925,6 +4927,259 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz",
"integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -4993,6 +5248,12 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -6789,6 +7050,428 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-dsv/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/date-format": { "node_modules/date-format": {
"version": "4.0.14", "version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
@ -6873,6 +7556,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -8505,6 +9197,15 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@ -11926,6 +12627,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": { "node_modules/rollup": {
"name": "@rollup/wasm-node", "name": "@rollup/wasm-node",
"version": "4.29.0", "version": "4.29.0",
@ -11984,6 +12691,12 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@ -12036,7 +12749,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {

View file

@ -21,6 +21,8 @@
"@angular/platform-browser-dynamic": "^19.0.5", "@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5", "@angular/router": "^19.0.5",
"@angular/service-worker": "^19.0.5", "@angular/service-worker": "^19.0.5",
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"sass": "^1.82.0", "sass": "^1.82.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",

View file

@ -7,3 +7,13 @@ export interface CallRecord {
tgid: number; tgid: number;
incidents: number; // in incident incidents: number; // in incident
} }
export interface CallStats {
stats: CallStatsRecord[];
interval: string;
}
export interface CallStatsRecord {
count: number;
time: Date;
}

View file

@ -101,7 +101,7 @@
</form> </form>
</div> </div>
<div class="tabContainer" *ngIf="!isLoading; else spinner"> <div class="tabContainer" *ngIf="!isLoading; else spinner">
<table class="callsTable" mat-table [dataSource]="callsResult"> <table #callsTable class="callsTable" mat-table [dataSource]="callsResult">
<ng-container matColumnDef="select"> <ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef> <th mat-header-cell *matHeaderCellDef>
<mat-checkbox <mat-checkbox
@ -159,7 +159,13 @@
<ng-container matColumnDef="talkgroup"> <ng-container matColumnDef="talkgroup">
<th mat-header-cell *matHeaderCellDef>Talkgroup</th> <th mat-header-cell *matHeaderCellDef>Talkgroup</th>
<td mat-cell *matCellDef="let call"> <td mat-cell *matCellDef="let call">
{{ call | talkgroup: "alpha" | async }} @let tgAlpha = call | talkgroup: "alpha" | async;
<a
href="javascript:void(0)"
class="tgFilter"
(click)="searchFilter(tgAlpha)"
>{{ tgAlpha }}</a
>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="duration"> <ng-container matColumnDef="duration">

View file

@ -100,3 +100,7 @@ form {
.in-incident { .in-incident {
background-color: rgb(59, 0, 59); background-color: rgb(59, 0, 59);
} }
a.tgFilter:hover {
text-decoration: underline;
}

View file

@ -1,7 +1,7 @@
import { Component, 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 { MatTableModule } from '@angular/material/table'; import { MatTable, MatTableModule } from '@angular/material/table';
import { import {
MatPaginator, MatPaginator,
MatPaginatorModule, MatPaginatorModule,
@ -80,6 +80,7 @@ const reqPageSize = 200;
export class CallsComponent { export class CallsComponent {
callsResult = new BehaviorSubject(new Array<CallRecord>(0)); callsResult = new BehaviorSubject(new Array<CallRecord>(0));
@ViewChild('paginator') paginator!: MatPaginator; @ViewChild('paginator') paginator!: MatPaginator;
@ViewChild('callsTable', { read: ElementRef }) callsTable!: ElementRef;
count = 0; count = 0;
dialog = inject(MatDialog); dialog = inject(MatDialog);
page = 0; page = 0;
@ -135,6 +136,12 @@ export class CallsComponent {
return numSelected === numRows; return numSelected === numRows;
} }
searchFilter(filt: string | null) {
if (filt) {
this.form.controls['filter'].setValue(filt);
}
}
buildParams(p: PageEvent, serverPage: number): CallsListParams { buildParams(p: PageEvent, serverPage: number): CallsListParams {
const par: CallsListParams = { const par: CallsListParams = {
start: new Date(this.form.controls['start'].value!), start: new Date(this.form.controls['start'].value!),
@ -251,6 +258,9 @@ export class CallsComponent {
this.isLoading = false; this.isLoading = false;
this.count = calls.count; this.count = calls.count;
this.currentSet = calls.calls; this.currentSet = calls.calls;
if (this.callsTable) {
this.callsTable.nativeElement.scrollIntoView(true);
}
this.callsResult.next( this.callsResult.next(
this.currentSet this.currentSet
? this.currentSet.slice( ? this.currentSet.slice(

View file

@ -1,7 +1,7 @@
import { Injectable, Pipe, PipeTransform } from '@angular/core'; import { Injectable, Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { CallRecord } from '../calls'; import { CallRecord, CallStats } from '../calls';
import { environment } from '.././../environments/environment'; import { environment } from '.././../environments/environment';
import { TalkgroupService } from '../talkgroups/talkgroups.service'; import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup'; import { Talkgroup } from '../talkgroup';
@ -185,4 +185,8 @@ export class CallsService {
callAudioDownloadURL(id: string): string { callAudioDownloadURL(id: string): string {
return environment.baseUrl + '/api/call/' + id + '/download'; return environment.baseUrl + '/api/call/' + id + '/download';
} }
getCallStats(interval: string): Observable<CallStats> {
return this.http.get<CallStats>(`/api/call/stats/${interval}`);
}
} }

View file

@ -0,0 +1 @@
<div class="chart"></div>

View file

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

View file

@ -0,0 +1,114 @@
import { Component, ElementRef, Input } from '@angular/core';
import * as d3 from 'd3';
import { CallsService } from '../calls/calls.service';
import { CallStatsRecord } from '../calls';
@Component({
selector: 'chart',
imports: [],
templateUrl: './charts.component.html',
styleUrl: './charts.component.scss',
})
export class ChartsComponent {
@Input() interval!: string;
loading = true;
// I hate javascript so much
months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
constructor(
private elementRef: ElementRef,
private callsSvc: CallsService,
) {}
dateFormat(d: Date): string {
switch (this.interval) {
case 'month':
return `${this.months[d.getMonth()]} ${d.getFullYear()}`;
case 'day':
return `${this.months[d.getMonth()]} ${d.getDate()}`;
case 'week':
return `${this.months[d.getMonth()]} ${d.getDate()}`;
}
return d.toDateString();
}
ngOnInit() {
this.callsSvc.getCallStats(this.interval).subscribe((stats) => {
let cMax = 0;
var cMin = 0;
let data = stats.stats.map((rec) => {
if (cMin == 0 && rec.count > cMin) {
cMin = rec.count;
}
if (rec.count < cMin) {
cMin = rec.count;
}
if (rec.count > cMax) {
cMax = rec.count;
}
return { count: rec.count, time: this.dateFormat(new Date(rec.time)) };
});
// 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;
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));
});
});
}
}

View file

@ -1 +1,4 @@
<p>This will be a dashboard someday.</p> <mat-card class="chart" appearance="outlined">
<div class="chartTitle">Calls By Week</div>
<chart interval="week"></chart>
</mat-card>

View file

@ -0,0 +1,10 @@
mat-card.chart {
width: 500px;
height: 400px;
margin: 30px 30px 40px 40px;
}
mat-card div {
padding-left: 10px;
padding-top: 10px;
}

View file

@ -1,8 +1,10 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ChartsComponent } from '../charts/charts.component';
import { MatCardModule } from '@angular/material/card';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [], imports: [ChartsComponent, MatCardModule],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrl: './home.component.scss', styleUrl: './home.component.scss',
}) })

View file

@ -12,6 +12,7 @@
[routerLink]="r.url" [routerLink]="r.url"
[routerLinkActiveOptions]="{ exact: r.exact }" [routerLinkActiveOptions]="{ exact: r.exact }"
routerLinkActive routerLinkActive
[title]="r.name"
#routerLinkActiveInstance="routerLinkActive" #routerLinkActiveInstance="routerLinkActive"
[activated]="routerLinkActiveInstance.isActive" [activated]="routerLinkActiveInstance.isActive"
mat-list-item mat-list-item

View file

@ -14,10 +14,7 @@ import { BehaviorSubject, Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { import { FormsModule, ReactiveFormsModule } from '@angular/forms';
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { import {
ShareListParams, ShareListParams,

View file

@ -1,6 +1,9 @@
package cache package cache
import "sync" import (
"sync"
"time"
)
type Cache[K comparable, V any] interface { type Cache[K comparable, V any] interface {
Get(K) (V, bool) Get(K) (V, bool)
@ -9,28 +12,67 @@ type Cache[K comparable, V any] interface {
Clear() Clear()
} }
type inMem[K comparable, V any] struct { type cacheItem[V any] struct {
sync.RWMutex exp int64
m map[K]V elem V
} }
func New[K comparable, V any]() *inMem[K, V] { type inMem[K comparable, V any] struct {
return &inMem[K, V]{ sync.RWMutex
m: make(map[K]V), expiration time.Duration
m map[K]cacheItem[V]
}
type cacheOpts struct {
exp time.Duration
}
type Option func(*cacheOpts)
func WithExpiration(d time.Duration) Option {
return func(o *cacheOpts) {
o.exp = d
} }
} }
func New[K comparable, V any](opts ...Option) *inMem[K, V] {
co := cacheOpts{}
for _, o := range opts {
o(&co)
}
c := &inMem[K, V]{
m: make(map[K]cacheItem[V]),
expiration: co.exp,
}
return c
}
func (c *inMem[K, V]) Get(key K) (V, bool) { func (c *inMem[K, V]) Get(key K) (V, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
v, ok := c.m[key]
return v, ok v, found := c.m[key]
if !found || (v.exp > 0 && time.Now().UnixNano() > v.exp) {
return v.elem, false
}
return v.elem, true
} }
func (c *inMem[K, V]) Set(key K, val V) { func (c *inMem[K, V]) Set(key K, val V) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
c.m[key] = val
var ci cacheItem[V]
ci.elem = val
if c.expiration > 0 {
ci.exp = time.Now().Add(c.expiration).UnixNano()
}
c.m[key] = ci
} }
func (c *inMem[K, V]) Delete(key K) { func (c *inMem[K, V]) Delete(key K) {

View file

@ -2,6 +2,7 @@ package cache_test
import ( import (
"testing" "testing"
"time"
"dynatron.me/x/stillbox/internal/cache" "dynatron.me/x/stillbox/internal/cache"
@ -31,3 +32,33 @@ func TestCache(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
assert.NotEqual(t, "fg", g) assert.NotEqual(t, "fg", g)
} }
func TestCacheTime(t *testing.T) {
c := cache.New[int, string](cache.WithExpiration(100 * time.Millisecond))
c.Set(4, "asd")
time.Sleep(20 * time.Millisecond)
g, ok := c.Get(4)
assert.Equal(t, "asd", g)
assert.True(t, ok)
c.Set(2, "gff")
time.Sleep(120 * time.Millisecond)
g, ok = c.Get(2)
assert.False(t, ok)
_, ok = c.Get(8)
assert.False(t, ok)
c.Set(7, "fg")
c.Delete(4)
g, ok = c.Get(4)
assert.False(t, ok)
assert.NotEqual(t, "asd", g)
c.Clear()
g, ok = c.Get(7)
assert.False(t, ok)
assert.NotEqual(t, "fg", g)
}

View file

@ -138,7 +138,7 @@ func (as *alerter) HUP(cfg *config.Config) {
// Go is the alerting loop. It does not start a goroutine. // Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) { func (as *alerter) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"}) ctx = entities.CtxWithServiceSubject(ctx, "alerter")
err := as.startBackfill(ctx) err := as.startBackfill(ctx)
if err != nil { if err != nil {

View file

@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
@ -35,14 +36,17 @@ type Store interface {
// Calls gets paginated Calls. // Calls gets paginated Calls.
Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error) Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error)
// CallStats gets call stats by interval.
CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error)
} }
type store struct { type postgresStore struct {
db database.Store db database.Store
} }
func NewStore(db database.Store) *store { func NewStore(db database.Store) *postgresStore {
return &store{ return &postgresStore{
db: db, db: db,
} }
} }
@ -52,11 +56,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context { func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return services.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok { if !ok {
panic("no call store in context") panic("no call store in context")
} }
@ -86,7 +90,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
} }
} }
func (s *store) AddCall(ctx context.Context, call *calls.Call) error { func (s *postgresStore) AddCall(ctx context.Context, call *calls.Call) error {
_, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate)) _, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate))
if err != nil { if err != nil {
return err return err
@ -124,7 +128,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
return nil return nil
} }
func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) { func (s *postgresStore) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
@ -145,7 +149,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, nil }, nil
} }
func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) { func (s *postgresStore) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
@ -195,7 +199,7 @@ type CallsParams struct {
AtLeastSeconds *float32 `json:"atLeastSeconds"` AtLeastSeconds *float32 `json:"atLeastSeconds"`
} }
func (s *store) 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) {
_, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead)) _, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -253,7 +257,7 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
return rows, int(count), err return rows, int(count), err
} }
func (s *store) Delete(ctx context.Context, id uuid.UUID) error { func (s *postgresStore) Delete(ctx context.Context, id uuid.UUID) error {
callOwn, err := s.getCallOwner(ctx, id) callOwn, err := s.getCallOwner(ctx, id)
if err != nil { if err != nil {
return err return err
@ -267,7 +271,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteCall(ctx, id) return database.FromCtx(ctx).DeleteCall(ctx, id)
} }
func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) { func (s *postgresStore) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id) subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id)
var sub *users.UserID var sub *users.UserID
@ -277,3 +281,35 @@ func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, err
} }
return calls.Call{ID: id, Submitter: sub}, err return calls.Call{ID: id, Submitter: sub}, err
} }
func (s *postgresStore) CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error) {
if !interval.IsValid() {
return nil, calls.ErrInvalidInterval
}
cs := &calls.Stats{
Interval: interval,
}
_, err := rbac.Check(ctx, cs, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
dbs, err := db.GetCallStatsByInterval(ctx, string(interval), start.PGTypeTSTZ(), end.PGTypeTSTZ())
if err != nil {
return nil, err
}
cs.Stats = make([]calls.Stat, 0, len(dbs))
for _, st := range dbs {
cs.Stats = append(cs.Stats, calls.Stat{
Count: st.Count,
Time: jsontypes.Time(st.Date.Time),
})
}
return cs, nil
}

46
pkg/calls/stats.go Normal file
View file

@ -0,0 +1,46 @@
package calls
import (
"errors"
"dynatron.me/x/stillbox/internal/jsontypes"
)
type Stats struct {
Stats []Stat `json:"stats"`
Interval StatsInterval `json:"interval"`
}
type Stat struct {
Count int64 `json:"count"`
Time jsontypes.Time `json:"time"`
}
var (
ErrInvalidInterval = errors.New("invalid interval")
)
func (s *Stats) GetResourceName() string {
return "CallStats"
}
type StatsInterval string
const (
IntervalMinute StatsInterval = "minute"
IntervalHour StatsInterval = "hour"
IntervalDay StatsInterval = "day"
IntervalWeek StatsInterval = "week"
IntervalMonth StatsInterval = "month"
IntervalQuarter StatsInterval = "quarter"
IntervalYear StatsInterval = "year"
)
func (si StatsInterval) IsValid() bool {
switch si {
case IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear:
return true
}
return false
}

View file

@ -7,6 +7,7 @@ import (
"strings" "strings"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/services"
sqlembed "dynatron.me/x/stillbox/sql" sqlembed "dynatron.me/x/stillbox/sql"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5" _ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
@ -144,7 +145,8 @@ const DBCtxKey dBCtxKey = "dbctx"
// FromCtx returns the database handle from the provided Context. // FromCtx returns the database handle from the provided Context.
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Store {
c, ok := ctx.Value(DBCtxKey).(Store) sv := services.Value(ctx, DBCtxKey)
c, ok := sv.(Store)
if !ok { if !ok {
panic("no DB in context") panic("no DB in context")
} }
@ -154,7 +156,7 @@ func FromCtx(ctx context.Context) Store {
// CtxWithDB returns a Context with the provided database handle. // CtxWithDB returns a Context with the provided database handle.
func CtxWithDB(ctx context.Context, conn Store) context.Context { func CtxWithDB(ctx context.Context, conn Store) context.Context {
return context.WithValue(ctx, DBCtxKey, conn) return services.WithValue(ctx, DBCtxKey, conn)
} }
// IsNoRows is a convenience function that returns whether a returned error is a database // IsNoRows is a convenience function that returns whether a returned error is a database

View file

@ -1520,6 +1520,128 @@ func (_c *Store_GetCallAudioByID_Call) RunAndReturn(run func(context.Context, uu
return _c return _c
} }
// GetCallStatsByInterval provides a mock function with given fields: ctx, truncField, start, end
func (_m *Store) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error) {
ret := _m.Called(ctx, truncField, start, end)
if len(ret) == 0 {
panic("no return value specified for GetCallStatsByInterval")
}
var r0 []database.GetCallStatsByIntervalRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)); ok {
return rf(ctx, truncField, start, end)
}
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByIntervalRow); ok {
r0 = rf(ctx, truncField, start, end)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetCallStatsByIntervalRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
r1 = rf(ctx, truncField, start, end)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetCallStatsByInterval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByInterval'
type Store_GetCallStatsByInterval_Call struct {
*mock.Call
}
// GetCallStatsByInterval is a helper method to define mock.On call
// - ctx context.Context
// - truncField string
// - start pgtype.Timestamptz
// - end pgtype.Timestamptz
func (_e *Store_Expecter) GetCallStatsByInterval(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByInterval_Call {
return &Store_GetCallStatsByInterval_Call{Call: _e.mock.On("GetCallStatsByInterval", ctx, truncField, start, end)}
}
func (_c *Store_GetCallStatsByInterval_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByInterval_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
})
return _c
}
func (_c *Store_GetCallStatsByInterval_Call) Return(_a0 []database.GetCallStatsByIntervalRow, _a1 error) *Store_GetCallStatsByInterval_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetCallStatsByInterval_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)) *Store_GetCallStatsByInterval_Call {
_c.Call.Return(run)
return _c
}
// GetCallStatsByTalkgroup provides a mock function with given fields: ctx, truncField, start, end
func (_m *Store) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error) {
ret := _m.Called(ctx, truncField, start, end)
if len(ret) == 0 {
panic("no return value specified for GetCallStatsByTalkgroup")
}
var r0 []database.GetCallStatsByTalkgroupRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)); ok {
return rf(ctx, truncField, start, end)
}
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByTalkgroupRow); ok {
r0 = rf(ctx, truncField, start, end)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetCallStatsByTalkgroupRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
r1 = rf(ctx, truncField, start, end)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetCallStatsByTalkgroup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByTalkgroup'
type Store_GetCallStatsByTalkgroup_Call struct {
*mock.Call
}
// GetCallStatsByTalkgroup is a helper method to define mock.On call
// - ctx context.Context
// - truncField string
// - start pgtype.Timestamptz
// - end pgtype.Timestamptz
func (_e *Store_Expecter) GetCallStatsByTalkgroup(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByTalkgroup_Call {
return &Store_GetCallStatsByTalkgroup_Call{Call: _e.mock.On("GetCallStatsByTalkgroup", ctx, truncField, start, end)}
}
func (_c *Store_GetCallStatsByTalkgroup_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
})
return _c
}
func (_c *Store_GetCallStatsByTalkgroup_Call) Return(_a0 []database.GetCallStatsByTalkgroupRow, _a1 error) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetCallStatsByTalkgroup_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Return(run)
return _c
}
// GetCallSubmitter provides a mock function with given fields: ctx, id // GetCallSubmitter provides a mock function with given fields: ctx, id
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) { func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View file

@ -135,7 +135,7 @@ func New(db database.Store, cfg config.Partition) (*partman, error) {
var _ PartitionManager = (*partman)(nil) var _ PartitionManager = (*partman)(nil)
func (pm *partman) Go(ctx context.Context) { func (pm *partman) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "partman"}) ctx = entities.CtxWithServiceSubject(ctx, "partman")
tick := time.NewTicker(CheckInterval) tick := time.NewTicker(CheckInterval)
select { select {

View file

@ -35,6 +35,8 @@ type Querier interface {
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error) GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error)
GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error)
GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error)
GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error)
GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
GetDatabaseSize(ctx context.Context) (string, error) GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error) GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)

99
pkg/database/stats.sql.go Normal file
View file

@ -0,0 +1,99 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: stats.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getCallStatsByInterval = `-- name: GetCallStatsByInterval :many
SELECT
COUNT(*),
date_trunc($1, c.call_date)::TIMESTAMPTZ date
FROM calls c
WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
GROUP BY date
ORDER BY date DESC
`
type GetCallStatsByIntervalRow struct {
Count int64 `json:"count"`
Date pgtype.Timestamptz `json:"date"`
}
func (q *Queries) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error) {
rows, err := q.db.Query(ctx, getCallStatsByInterval, truncField, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCallStatsByIntervalRow
for rows.Next() {
var i GetCallStatsByIntervalRow
if err := rows.Scan(&i.Count, &i.Date); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getCallStatsByTalkgroup = `-- name: GetCallStatsByTalkgroup :many
SELECT
COUNT(*),
c.system,
c.talkgroup,
date_trunc($1, c.call_date)
FROM calls c
WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
GROUP BY 2, 3, 4
ORDER BY 4 DESC
`
type GetCallStatsByTalkgroupRow struct {
Count int64 `json:"count"`
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
DateTrunc pgtype.Interval `json:"dateTrunc"`
}
func (q *Queries) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error) {
rows, err := q.db.Query(ctx, getCallStatsByTalkgroup, truncField, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCallStatsByTalkgroupRow
for rows.Next() {
var i GetCallStatsByTalkgroupRow
if err := rows.Scan(
&i.Count,
&i.System,
&i.Talkgroup,
&i.DateTrunc,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -11,6 +11,7 @@ import (
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid" "github.com/google/uuid"
@ -67,11 +68,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context { func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return services.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok { if !ok {
return NewStore() return NewStore()
} }

View file

@ -39,7 +39,7 @@ func New() *Nexus {
} }
func (n *Nexus) Go(ctx context.Context) { func (n *Nexus) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "nexus"}) ctx = entities.CtxWithServiceSubject(ctx, "nexus")
for { for {
select { select {
case call, ok := <-n.callCh: case call, ok := <-n.callCh:

View file

@ -22,6 +22,7 @@ const (
ResourceAlert = "Alert" ResourceAlert = "Alert"
ResourceShare = "Share" ResourceShare = "Share"
ResourceAPIKey = "APIKey" ResourceAPIKey = "APIKey"
ResourceCallStats = "CallStats"
ActionRead = "read" ActionRead = "read"
ActionCreate = "create" ActionCreate = "create"
@ -49,6 +50,10 @@ func CtxWithSubject(ctx context.Context, sub Subject) context.Context {
return context.WithValue(ctx, SubjectCtxKey, sub) return context.WithValue(ctx, SubjectCtxKey, sub)
} }
func CtxWithServiceSubject(ctx context.Context, name string) context.Context {
return CtxWithSubject(ctx, &SystemServiceSubject{Name: name})
}
type subjectContextKey string type subjectContextKey string
const SubjectCtxKey subjectContextKey = "sub" const SubjectCtxKey subjectContextKey = "sub"

View file

@ -47,6 +47,9 @@ var Policy = &restrict.PolicyDefinition{
&restrict.Permission{Preset: PresetUpdateOwn}, &restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn}, &restrict.Permission{Preset: PresetDeleteOwn},
}, },
entities.ResourceCallStats: {
&restrict.Permission{Action: entities.ActionRead},
},
}, },
}, },
entities.RoleSubmitter: { entities.RoleSubmitter: {
@ -112,6 +115,7 @@ var Policy = &restrict.PolicyDefinition{
Parents: []string{entities.RoleAdmin}, Parents: []string{entities.RoleAdmin},
}, },
entities.RolePublic: { entities.RolePublic: {
Description: "Everybody else",
Grants: restrict.GrantsMap{ Grants: restrict.GrantsMap{
entities.ResourceShare: { entities.ResourceShare: {
&restrict.Permission{Action: entities.ActionRead}, &restrict.Permission{Action: entities.ActionRead},

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2"
"github.com/el-mike/restrict/v2/adapters" "github.com/el-mike/restrict/v2/adapters"
@ -12,7 +13,7 @@ import (
) )
var ( var (
ErrBadSubject = errors.New("bad subject in token") ErrBadSubject = errors.New("bad subject in token")
ErrAccessDenied = errors.New("access denied") ErrAccessDenied = errors.New("access denied")
) )
@ -33,7 +34,7 @@ type rbacCtxKey string
const RBACCtxKey rbacCtxKey = "rbac" const RBACCtxKey rbacCtxKey = "rbac"
func FromCtx(ctx context.Context) RBAC { func FromCtx(ctx context.Context) RBAC {
rbac, ok := ctx.Value(RBACCtxKey).(RBAC) rbac, ok := services.Value(ctx, RBACCtxKey).(RBAC)
if !ok { if !ok {
panic("no RBAC in context") panic("no RBAC in context")
} }
@ -42,7 +43,7 @@ func FromCtx(ctx context.Context) RBAC {
} }
func CtxWithRBAC(ctx context.Context, rbac RBAC) context.Context { func CtxWithRBAC(ctx context.Context, rbac RBAC) context.Context {
return context.WithValue(ctx, RBACCtxKey, rbac) return services.WithValue(ctx, RBACCtxKey, rbac)
} }
var ( var (

View file

@ -6,6 +6,7 @@ import (
"net/url" "net/url"
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -165,6 +166,7 @@ var statusMapping = map[error]errResponder{
shares.ErrNoShare: notFoundErrText, shares.ErrNoShare: notFoundErrText,
ErrBadShare: notFoundErrText, ErrBadShare: notFoundErrText,
shares.ErrBadType: badRequestErrText, shares.ErrBadType: badRequestErrText,
calls.ErrInvalidInterval: badRequestErrText,
} }
func autoError(err error) render.Renderer { func autoError(err error) render.Renderer {

View file

@ -10,8 +10,10 @@ import (
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/stats"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
@ -34,6 +36,7 @@ func (ca *callsAPI) Subrouter() http.Handler {
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute) r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute)
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute) r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute)
r.Post(`/`, ca.listCalls) r.Post(`/`, ca.listCalls)
r.Get(`/stats/{interval}`, ca.getCallStats)
return r return r
} }
@ -55,6 +58,29 @@ func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) {
ca.getAudio(p, w, r) ca.getAudio(p, w, r)
} }
func (ca *callsAPI) getCallStats(w http.ResponseWriter, r *http.Request) {
p := struct {
Interval calls.StatsInterval `param:"interval"`
}{}
err := decodeParams(&p, r)
if err != nil {
wErr(w, r, badRequest(err))
return
}
ctx := r.Context()
sts := stats.FromCtx(ctx)
st, err := sts.GetCallStats(ctx, p.Interval)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, st)
}
func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) { func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) {
if p.CallID == nil { if p.CallID == nil {
wErr(w, r, badRequest(ErrNoCall)) wErr(w, r, badRequest(ErrNoCall))

View file

@ -19,9 +19,11 @@ import (
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/rest"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources" "dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/stats"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
@ -54,6 +56,7 @@ type Server struct {
incidents incstore.Store incidents incstore.Store
share shares.Service share shares.Service
rbac rbac.RBAC rbac rbac.RBAC
stats stats.Stats
} }
func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
@ -79,13 +82,16 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
} }
tgCache := tgstore.NewCache(db) tgCache := tgstore.NewCache(db)
api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New(policy.Policy) rbacSvc, err := rbac.New(policy.Policy)
if err != nil { if err != nil {
return nil, err return nil, err
} }
callStore := callstore.NewStore(db)
statsSvc := stats.NewStats(callStore, stats.DefaultExpiration)
api := rest.New(cfg.BaseURL.URL())
srv := &Server{ srv := &Server{
auth: authenticator, auth: authenticator,
conf: cfg, conf: cfg,
@ -100,9 +106,10 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
rest: api, rest: api,
share: shares.NewService(), share: shares.NewService(),
users: ust, users: ust,
calls: callstore.NewStore(db), calls: callStore,
incidents: incstore.NewStore(), incidents: incstore.NewStore(),
rbac: rbacSvc, rbac: rbacSvc,
stats: statsSvc,
} }
if cfg.DB.Partition.Enabled { if cfg.DB.Partition.Enabled {
@ -158,6 +165,9 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
} }
func (s *Server) fillCtx(ctx context.Context) context.Context { func (s *Server) fillCtx(ctx context.Context) context.Context {
svc := services.New()
ctx = services.CtxWith(ctx, svc)
ctx = database.CtxWithDB(ctx, s.db) ctx = database.CtxWithDB(ctx, s.db)
ctx = tgstore.CtxWithStore(ctx, s.tgs) ctx = tgstore.CtxWithStore(ctx, s.tgs)
ctx = users.CtxWithStore(ctx, s.users) ctx = users.CtxWithStore(ctx, s.users)
@ -165,6 +175,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context {
ctx = incstore.CtxWithStore(ctx, s.incidents) ctx = incstore.CtxWithStore(ctx, s.incidents)
ctx = shares.CtxWithStore(ctx, s.share) ctx = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = rbac.CtxWithRBAC(ctx, s.rbac)
ctx = stats.CtxWithStats(ctx, s.stats)
return ctx return ctx
} }

66
pkg/services/services.go Normal file
View file

@ -0,0 +1,66 @@
// Package services avoids having to wrap contexts so much for multiple services.
package services
import (
"context"
"sync"
)
type Services interface {
Set(key, val any)
Value(key any) any
}
type services struct {
sync.RWMutex
m map[any]any
}
func New() Services {
return &services{
m: make(map[any]any),
}
}
func CtxWith(ctx context.Context, svc Services) context.Context {
return context.WithValue(ctx, ServiceKey, svc)
}
func (s *services) Value(key any) any {
s.RLock()
defer s.RUnlock()
return s.m[key]
}
func (s *services) Set(key, val any) {
s.Lock()
defer s.Unlock()
s.m[key] = val
}
type serviceKey string
const ServiceKey serviceKey = "service"
func WithValue(ctx context.Context, key, val any) context.Context {
v := ctx.Value(ServiceKey)
if v == nil {
return context.WithValue(ctx, key, val)
}
sv := v.(Services)
sv.Set(key, val)
return ctx
}
func Value(ctx context.Context, key any) any {
sv := ctx.Value(ServiceKey)
if sv == nil {
return ctx.Value(key)
}
return sv.(Services).Value(key)
}

View file

@ -23,7 +23,7 @@ type service struct {
} }
func (s *service) Go(ctx context.Context) { func (s *service) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "share"}) ctx = entities.CtxWithServiceSubject(ctx, "share")
tick := time.NewTicker(PruneInterval) tick := time.NewTicker(PruneInterval)

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@ -169,11 +170,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Shares) context.Context { func CtxWithStore(ctx context.Context, s Shares) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return services.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Shares { func FromCtx(ctx context.Context) Shares {
s, ok := ctx.Value(StoreCtxKey).(Shares) s, ok := services.Value(ctx, StoreCtxKey).(Shares)
if !ok { if !ok {
panic("no shares store in context") panic("no shares store in context")
} }

80
pkg/stats/stats.go Normal file
View file

@ -0,0 +1,80 @@
package stats
import (
"context"
"time"
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/services"
)
const DefaultExpiration = 5 * time.Minute
type Stats interface {
GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error)
}
type statsKeyType string
const StatsKey statsKeyType = "stats"
func CtxWithStats(ctx context.Context, s Stats) context.Context {
return services.WithValue(ctx, StatsKey, s)
}
func FromCtx(ctx context.Context) Stats {
s, ok := services.Value(ctx, StatsKey).(Stats)
if !ok {
panic("no stats in context")
}
return s
}
type stats struct {
cs callstore.Store
sc cache.Cache[calls.StatsInterval, *calls.Stats]
}
func NewStats(cst callstore.Store, cacheExp time.Duration) *stats {
s := &stats{
cs: cst,
sc: cache.New[calls.StatsInterval, *calls.Stats](cache.WithExpiration(cacheExp)),
}
return s
}
func (s *stats) GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error) {
st, has := s.sc.Get(interval)
if has {
return st, nil
}
var start time.Time
now := time.Now()
switch interval {
case calls.IntervalHour:
start = now.Add(-24 * time.Hour) // one day
case calls.IntervalDay:
start = now.Add(-7 * 24 * time.Hour) // one week
case calls.IntervalWeek:
start = now.Add(-30 * 24 * time.Hour) // one month
case calls.IntervalMonth:
start = now.Add(-365 * 24 * time.Hour) // one year
default:
return nil, calls.ErrInvalidInterval
}
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(now))
if err != nil {
return nil, err
}
s.sc.Set(interval, st)
return st, nil
}

View file

@ -13,6 +13,7 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups" tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
@ -172,11 +173,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context { func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return services.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok { if !ok {
panic("no tg store in context") panic("no tg store in context")
} }

View file

@ -6,6 +6,7 @@ import (
"dynatron.me/x/stillbox/internal/cache" "dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/services"
) )
var ( var (
@ -49,11 +50,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context { func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s) return services.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok { if !ok {
panic("no users store in context") panic("no users store in context")
} }

View file

@ -72,7 +72,7 @@ func (u *User) GetName() string {
} }
func (u *User) String() string { func (u *User) String() string {
return "USER:"+u.GetName() return "USER:" + u.GetName()
} }
func (u *User) GetRoles() []string { func (u *User) GetRoles() []string {

View file

@ -0,0 +1,27 @@
-- name: GetCallStatsByTalkgroup :many
SELECT
COUNT(*),
c.system,
c.talkgroup,
date_trunc(@trunc_field, c.call_date)
FROM calls c
WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
GROUP BY 2, 3, 4
ORDER BY 4 DESC;
-- name: GetCallStatsByInterval :many
SELECT
COUNT(*),
date_trunc(@trunc_field, c.call_date)::TIMESTAMPTZ date
FROM calls c
WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
GROUP BY date
ORDER BY date DESC;