Merge pull request 'Call statistics' (#112) from callStats into trunk
Reviewed-on: #112
This commit is contained in:
commit
02d0befd75
42 changed files with 1557 additions and 53 deletions
714
client/stillbox/package-lock.json
generated
714
client/stillbox/package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -100,3 +100,7 @@ form {
|
|||
.in-incident {
|
||||
background-color: rgb(59, 0, 59);
|
||||
}
|
||||
|
||||
a.tgFilter:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
1
client/stillbox/src/app/charts/charts.component.html
Normal file
1
client/stillbox/src/app/charts/charts.component.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="chart"></div>
|
0
client/stillbox/src/app/charts/charts.component.scss
Normal file
0
client/stillbox/src/app/charts/charts.component.scss
Normal file
22
client/stillbox/src/app/charts/charts.component.spec.ts
Normal file
22
client/stillbox/src/app/charts/charts.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
114
client/stillbox/src/app/charts/charts.component.ts
Normal file
114
client/stillbox/src/app/charts/charts.component.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
[routerLink]="r.url"
|
||||
[routerLinkActiveOptions]="{ exact: r.exact }"
|
||||
routerLinkActive
|
||||
[title]="r.name"
|
||||
#routerLinkActiveInstance="routerLinkActive"
|
||||
[activated]="routerLinkActiveInstance.isActive"
|
||||
mat-list-item
|
||||
|
|
|
@ -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,
|
||||
|
|
62
internal/cache/cache.go
vendored
62
internal/cache/cache.go
vendored
|
@ -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) {
|
||||
|
|
31
internal/cache/cache_test.go
vendored
31
internal/cache/cache_test.go
vendored
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
46
pkg/calls/stats.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
99
pkg/database/stats.sql.go
Normal 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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
66
pkg/services/services.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
80
pkg/stats/stats.go
Normal 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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
27
sql/postgres/queries/stats.sql
Normal file
27
sql/postgres/queries/stats.sql
Normal 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;
|
Loading…
Add table
Reference in a new issue