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/router": "^19.0.5",
"@angular/service-worker": "^19.0.5",
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"rxjs": "~7.8.0",
"sass": "^1.82.0",
"tslib": "^2.3.0",
@ -4925,6 +4927,259 @@
"@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": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -4993,6 +5248,12 @@
"@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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -6789,6 +7050,428 @@
"dev": true,
"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": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
@ -6873,6 +7556,15 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -8505,6 +9197,15 @@
"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": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@ -11926,6 +12627,12 @@
"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": {
"name": "@rollup/wasm-node",
"version": "4.29.0",
@ -11984,6 +12691,12 @@
"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": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@ -12036,7 +12749,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/sass": {

View file

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

View file

@ -7,3 +7,13 @@ export interface CallRecord {
tgid: number;
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>
</div>
<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">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
@ -159,7 +159,13 @@
<ng-container matColumnDef="talkgroup">
<th mat-header-cell *matHeaderCellDef>Talkgroup</th>
<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>
</ng-container>
<ng-container matColumnDef="duration">

View file

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

View file

@ -1,7 +1,7 @@
import { Injectable, Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { CallRecord } from '../calls';
import { CallRecord, CallStats } from '../calls';
import { environment } from '.././../environments/environment';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
@ -185,4 +185,8 @@ export class CallsService {
callAudioDownloadURL(id: string): string {
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 { ChartsComponent } from '../charts/charts.component';
import { MatCardModule } from '@angular/material/card';
@Component({
selector: 'app-home',
imports: [],
imports: [ChartsComponent, MatCardModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})

View file

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

View file

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

View file

@ -1,6 +1,9 @@
package cache
import "sync"
import (
"sync"
"time"
)
type Cache[K comparable, V any] interface {
Get(K) (V, bool)
@ -9,28 +12,67 @@ type Cache[K comparable, V any] interface {
Clear()
}
type inMem[K comparable, V any] struct {
sync.RWMutex
m map[K]V
type cacheItem[V any] struct {
exp int64
elem V
}
func New[K comparable, V any]() *inMem[K, V] {
return &inMem[K, V]{
m: make(map[K]V),
type inMem[K comparable, V any] struct {
sync.RWMutex
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) {
c.RLock()
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) {
c.Lock()
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) {

View file

@ -2,6 +2,7 @@ package cache_test
import (
"testing"
"time"
"dynatron.me/x/stillbox/internal/cache"
@ -31,3 +32,33 @@ func TestCache(t *testing.T) {
assert.False(t, ok)
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.
func (as *alerter) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"})
ctx = entities.CtxWithServiceSubject(ctx, "alerter")
err := as.startBackfill(ctx)
if err != nil {

View file

@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"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/users"
@ -35,14 +36,17 @@ type Store interface {
// Calls gets paginated Calls.
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
}
func NewStore(db database.Store) *store {
return &store{
func NewStore(db database.Store) *postgresStore {
return &postgresStore{
db: db,
}
}
@ -52,11 +56,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
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 {
s, ok := ctx.Value(StoreCtxKey).(Store)
s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
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))
if err != nil {
return err
@ -124,7 +128,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
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))
if err != nil {
return nil, err
@ -145,7 +149,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, 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))
if err != nil {
return nil, err
@ -195,7 +199,7 @@ type CallsParams struct {
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))
if err != nil {
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
}
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)
if err != nil {
return err
@ -267,7 +271,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
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)
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
}
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"
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/services"
sqlembed "dynatron.me/x/stillbox/sql"
"github.com/golang-migrate/migrate/v4"
_ "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.
func FromCtx(ctx context.Context) Store {
c, ok := ctx.Value(DBCtxKey).(Store)
sv := services.Value(ctx, DBCtxKey)
c, ok := sv.(Store)
if !ok {
panic("no DB in context")
}
@ -154,7 +156,7 @@ func FromCtx(ctx context.Context) Store {
// CtxWithDB returns a Context with the provided database handle.
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

View file

@ -1520,6 +1520,128 @@ func (_c *Store_GetCallAudioByID_Call) RunAndReturn(run func(context.Context, uu
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
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
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)
func (pm *partman) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "partman"})
ctx = entities.CtxWithServiceSubject(ctx, "partman")
tick := time.NewTicker(CheckInterval)
select {

View file

@ -35,6 +35,8 @@ type Querier interface {
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, 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)
GetDatabaseSize(ctx context.Context) (string, 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/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid"
@ -67,11 +68,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
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 {
s, ok := ctx.Value(StoreCtxKey).(Store)
s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
return NewStore()
}

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import (
"errors"
"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/adapters"
@ -12,7 +13,7 @@ import (
)
var (
ErrBadSubject = errors.New("bad subject in token")
ErrBadSubject = errors.New("bad subject in token")
ErrAccessDenied = errors.New("access denied")
)
@ -33,7 +34,7 @@ type rbacCtxKey string
const RBACCtxKey rbacCtxKey = "rbac"
func FromCtx(ctx context.Context) RBAC {
rbac, ok := ctx.Value(RBACCtxKey).(RBAC)
rbac, ok := services.Value(ctx, RBACCtxKey).(RBAC)
if !ok {
panic("no RBAC in context")
}
@ -42,7 +43,7 @@ func FromCtx(ctx context.Context) RBAC {
}
func CtxWithRBAC(ctx context.Context, rbac RBAC) context.Context {
return context.WithValue(ctx, RBACCtxKey, rbac)
return services.WithValue(ctx, RBACCtxKey, rbac)
}
var (

View file

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

View file

@ -10,8 +10,10 @@ import (
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/stats"
"github.com/go-chi/chi/v5"
"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-]+}/{download:download}`, ca.getAudioRoute)
r.Post(`/`, ca.listCalls)
r.Get(`/stats/{interval}`, ca.getCallStats)
return r
}
@ -55,6 +58,29 @@ func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) {
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) {
if p.CallID == nil {
wErr(w, r, badRequest(ErrNoCall))

View file

@ -19,9 +19,11 @@ import (
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/stats"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users"
@ -54,6 +56,7 @@ type Server struct {
incidents incstore.Store
share shares.Service
rbac rbac.RBAC
stats stats.Stats
}
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)
api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New(policy.Policy)
if err != nil {
return nil, err
}
callStore := callstore.NewStore(db)
statsSvc := stats.NewStats(callStore, stats.DefaultExpiration)
api := rest.New(cfg.BaseURL.URL())
srv := &Server{
auth: authenticator,
conf: cfg,
@ -100,9 +106,10 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
rest: api,
share: shares.NewService(),
users: ust,
calls: callstore.NewStore(db),
calls: callStore,
incidents: incstore.NewStore(),
rbac: rbacSvc,
stats: statsSvc,
}
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 {
svc := services.New()
ctx = services.CtxWith(ctx, svc)
ctx = database.CtxWithDB(ctx, s.db)
ctx = tgstore.CtxWithStore(ctx, s.tgs)
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 = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac)
ctx = stats.CtxWithStats(ctx, s.stats)
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) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "share"})
ctx = entities.CtxWithServiceSubject(ctx, "share")
tick := time.NewTicker(PruneInterval)

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/users"
"github.com/jackc/pgx/v5"
)
@ -169,11 +170,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
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 {
s, ok := ctx.Value(StoreCtxKey).(Shares)
s, ok := services.Value(ctx, StoreCtxKey).(Shares)
if !ok {
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/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/services"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
@ -172,11 +173,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
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 {
s, ok := ctx.Value(StoreCtxKey).(Store)
s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
panic("no tg store in context")
}

View file

@ -6,6 +6,7 @@ import (
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/services"
)
var (
@ -49,11 +50,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
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 {
s, ok := ctx.Value(StoreCtxKey).(Store)
s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
panic("no users store in context")
}

View file

@ -72,7 +72,7 @@ func (u *User) GetName() string {
}
func (u *User) String() string {
return "USER:"+u.GetName()
return "USER:" + u.GetName()
}
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;