diff --git a/client/stillbox/package-lock.json b/client/stillbox/package-lock.json index bb5a79a..1267460 100644 --- a/client/stillbox/package-lock.json +++ b/client/stillbox/package-lock.json @@ -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": { diff --git a/client/stillbox/package.json b/client/stillbox/package.json index 33e1725..c275707 100644 --- a/client/stillbox/package.json +++ b/client/stillbox/package.json @@ -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", diff --git a/client/stillbox/src/app/calls.ts b/client/stillbox/src/app/calls.ts index ed581f5..a38502f 100644 --- a/client/stillbox/src/app/calls.ts +++ b/client/stillbox/src/app/calls.ts @@ -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; +} diff --git a/client/stillbox/src/app/calls/calls.component.html b/client/stillbox/src/app/calls/calls.component.html index 3a3972f..86ce14f 100644 --- a/client/stillbox/src/app/calls/calls.component.html +++ b/client/stillbox/src/app/calls/calls.component.html @@ -101,7 +101,7 @@
- +
diff --git a/client/stillbox/src/app/calls/calls.component.scss b/client/stillbox/src/app/calls/calls.component.scss index 01e9628..7d7bbd3 100644 --- a/client/stillbox/src/app/calls/calls.component.scss +++ b/client/stillbox/src/app/calls/calls.component.scss @@ -100,3 +100,7 @@ form { .in-incident { background-color: rgb(59, 0, 59); } + +a.tgFilter:hover { + text-decoration: underline; +} diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts index 76b50df..030180f 100644 --- a/client/stillbox/src/app/calls/calls.component.ts +++ b/client/stillbox/src/app/calls/calls.component.ts @@ -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(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( diff --git a/client/stillbox/src/app/calls/calls.service.ts b/client/stillbox/src/app/calls/calls.service.ts index 31816ff..570563f 100644 --- a/client/stillbox/src/app/calls/calls.service.ts +++ b/client/stillbox/src/app/calls/calls.service.ts @@ -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 { + return this.http.get(`/api/call/stats/${interval}`); + } } diff --git a/client/stillbox/src/app/charts/charts.component.html b/client/stillbox/src/app/charts/charts.component.html new file mode 100644 index 0000000..439281b --- /dev/null +++ b/client/stillbox/src/app/charts/charts.component.html @@ -0,0 +1 @@ +
diff --git a/client/stillbox/src/app/charts/charts.component.scss b/client/stillbox/src/app/charts/charts.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/stillbox/src/app/charts/charts.component.spec.ts b/client/stillbox/src/app/charts/charts.component.spec.ts new file mode 100644 index 0000000..4c98a0b --- /dev/null +++ b/client/stillbox/src/app/charts/charts.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChartsComponent } from './charts.component'; + +describe('ChartsComponent', () => { + let component: ChartsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChartsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ChartsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/charts/charts.component.ts b/client/stillbox/src/app/charts/charts.component.ts new file mode 100644 index 0000000..707ed5e --- /dev/null +++ b/client/stillbox/src/app/charts/charts.component.ts @@ -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)); + }); + }); + } +} diff --git a/client/stillbox/src/app/home/home.component.html b/client/stillbox/src/app/home/home.component.html index 841e271..191617f 100644 --- a/client/stillbox/src/app/home/home.component.html +++ b/client/stillbox/src/app/home/home.component.html @@ -1 +1,4 @@ -

This will be a dashboard someday.

+ +
Calls By Week
+ +
diff --git a/client/stillbox/src/app/home/home.component.scss b/client/stillbox/src/app/home/home.component.scss index e69de29..06e84df 100644 --- a/client/stillbox/src/app/home/home.component.scss +++ b/client/stillbox/src/app/home/home.component.scss @@ -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; +} diff --git a/client/stillbox/src/app/home/home.component.ts b/client/stillbox/src/app/home/home.component.ts index f1f850e..0c5d88d 100644 --- a/client/stillbox/src/app/home/home.component.ts +++ b/client/stillbox/src/app/home/home.component.ts @@ -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', }) diff --git a/client/stillbox/src/app/navigation/navigation.component.html b/client/stillbox/src/app/navigation/navigation.component.html index 692a97f..b90a54b 100644 --- a/client/stillbox/src/app/navigation/navigation.component.html +++ b/client/stillbox/src/app/navigation/navigation.component.html @@ -12,6 +12,7 @@ [routerLink]="r.url" [routerLinkActiveOptions]="{ exact: r.exact }" routerLinkActive + [title]="r.name" #routerLinkActiveInstance="routerLinkActive" [activated]="routerLinkActiveInstance.isActive" mat-list-item diff --git a/client/stillbox/src/app/shares/shares.component.ts b/client/stillbox/src/app/shares/shares.component.ts index b7784ef..47132cd 100644 --- a/client/stillbox/src/app/shares/shares.component.ts +++ b/client/stillbox/src/app/shares/shares.component.ts @@ -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, diff --git a/internal/cache/cache.go b/internal/cache/cache.go index a364406..85b7823 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -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) { diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 23079f1..d7102ed 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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) +} diff --git a/pkg/alerting/alerting.go b/pkg/alerting/alerting.go index 769781b..a5ad0aa 100644 --- a/pkg/alerting/alerting.go +++ b/pkg/alerting/alerting.go @@ -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 { diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 297ddbb..d446b52 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -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 +} diff --git a/pkg/calls/stats.go b/pkg/calls/stats.go new file mode 100644 index 0000000..7190c5c --- /dev/null +++ b/pkg/calls/stats.go @@ -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 +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 874ab30..bf28295 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -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 diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index be3d593..34021cd 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -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) diff --git a/pkg/database/partman/partman.go b/pkg/database/partman/partman.go index 391e284..9b7afcd 100644 --- a/pkg/database/partman/partman.go +++ b/pkg/database/partman/partman.go @@ -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 { diff --git a/pkg/database/querier.go b/pkg/database/querier.go index 36e68d7..f2c7e35 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -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) diff --git a/pkg/database/stats.sql.go b/pkg/database/stats.sql.go new file mode 100644 index 0000000..3db6ae8 --- /dev/null +++ b/pkg/database/stats.sql.go @@ -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 +} diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 81ce14d..5bddd5c 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -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() } diff --git a/pkg/nexus/nexus.go b/pkg/nexus/nexus.go index 84d4020..766eaaf 100644 --- a/pkg/nexus/nexus.go +++ b/pkg/nexus/nexus.go @@ -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: diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go index 42e1548..62481a0 100644 --- a/pkg/rbac/entities/entities.go +++ b/pkg/rbac/entities/entities.go @@ -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" diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go index 6cf50c1..b6d8634 100644 --- a/pkg/rbac/policy/policy.go +++ b/pkg/rbac/policy/policy.go @@ -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}, diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index b153885..9604bc2 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -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 ( diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 6fc0fd8..6167393 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -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 { diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index cdbc5bf..01478cc 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -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)) diff --git a/pkg/server/server.go b/pkg/server/server.go index a2553d7..ec8d07b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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 } diff --git a/pkg/services/services.go b/pkg/services/services.go new file mode 100644 index 0000000..9288bf1 --- /dev/null +++ b/pkg/services/services.go @@ -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) +} diff --git a/pkg/shares/service.go b/pkg/shares/service.go index 245b61d..65f4dfa 100644 --- a/pkg/shares/service.go +++ b/pkg/shares/service.go @@ -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) diff --git a/pkg/shares/store.go b/pkg/shares/store.go index f44c524..0f3308e 100644 --- a/pkg/shares/store.go +++ b/pkg/shares/store.go @@ -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") } diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go new file mode 100644 index 0000000..b1e03bd --- /dev/null +++ b/pkg/stats/stats.go @@ -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 +} diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index b7dd472..bcad6da 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -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") } diff --git a/pkg/users/store.go b/pkg/users/store.go index 5722ab2..7326429 100644 --- a/pkg/users/store.go +++ b/pkg/users/store.go @@ -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") } diff --git a/pkg/users/user.go b/pkg/users/user.go index b5d5d27..e505da5 100644 --- a/pkg/users/user.go +++ b/pkg/users/user.go @@ -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 { diff --git a/sql/postgres/queries/stats.sql b/sql/postgres/queries/stats.sql new file mode 100644 index 0000000..e201da8 --- /dev/null +++ b/sql/postgres/queries/stats.sql @@ -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;
Talkgroup - {{ call | talkgroup: "alpha" | async }} + @let tgAlpha = call | talkgroup: "alpha" | async; + {{ tgAlpha }}