mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +02:00
Audit fixes and improvements (#5835)
This commit is contained in:
Generated
+391
@@ -72,6 +72,7 @@
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"recharts": "^3.7.0",
|
||||
"signature_pad": "^5.0.4",
|
||||
"smol-toml": "^1.4.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
@@ -3013,6 +3014,42 @@
|
||||
"react": "16.x || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -3397,6 +3434,18 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
|
||||
@@ -4495,6 +4544,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"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-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-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-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-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"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-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/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -4637,6 +4749,12 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -6329,6 +6447,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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-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-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-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"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-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-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/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
@@ -6405,6 +6644,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz",
|
||||
@@ -6963,6 +7208,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -8116,6 +8371,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8179,6 +8444,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": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -10803,6 +11077,29 @@
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -11112,6 +11409,42 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -11126,6 +11459,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -11174,6 +11522,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
@@ -12195,6 +12549,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -12661,6 +13021,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -12685,6 +13054,28 @@
|
||||
"spdx-expression-parse": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/visit-values": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/visit-values/-/visit-values-2.0.0.tgz",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"recharts": "^3.7.0",
|
||||
"signature_pad": "^5.0.4",
|
||||
"smol-toml": "^1.4.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
|
||||
@@ -1164,19 +1164,43 @@ title = "Security"
|
||||
|
||||
[admin.settings.security.audit]
|
||||
label = "Audit Logging"
|
||||
showDetails = "Show audit level details"
|
||||
hideDetails = "Hide audit level details"
|
||||
|
||||
[admin.settings.security.audit.enabled]
|
||||
description = "Track user actions and system events for compliance and security monitoring"
|
||||
label = "Enable Audit Logging"
|
||||
|
||||
[admin.settings.security.audit.level]
|
||||
description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
|
||||
description = "Audit Level: OFF (0) = no logging | BASIC (1) = file modifications (compress/split/merge/etc) & settings | STANDARD (2) = file operations + user actions | VERBOSE (3) = all requests including polling. Higher levels include all events from lower levels."
|
||||
label = "Audit Level"
|
||||
|
||||
[admin.settings.security.audit.levelDetails]
|
||||
off = "No audit logging (except critical security events)"
|
||||
basic = "PDF file operations (compress, split, merge, etc.) and settings changes. Captures: operation status (success/failure), method parameters, timing. Minimal log volume."
|
||||
standard = "BASIC events plus: user login/logout, account changes, general GET requests. Excludes continuous polling calls (/auth/me, /app-config, /health, etc.) to reduce log noise. Ideal for most deployments."
|
||||
verbose = "STANDARD events plus: continuous polling calls, all GET requests, detailed timing. Warning: high log volume and performance impact."
|
||||
|
||||
[admin.settings.security.audit.retentionDays]
|
||||
description = "Number of days to retain audit logs"
|
||||
description = "Number of days to retain audit logs (0 = infinite retention)"
|
||||
label = "Audit Retention (days)"
|
||||
|
||||
[admin.settings.security.audit.advancedOptions]
|
||||
title = "Advanced Options"
|
||||
description = "The following options increase processing time and memory usage. Enable only if truly needed."
|
||||
|
||||
[admin.settings.security.audit.captureFileHash]
|
||||
description = "Extract SHA-256 hash of uploaded PDF files for integrity verification. Independent of audit level."
|
||||
label = "Capture File Hash (SHA-256)"
|
||||
|
||||
[admin.settings.security.audit.capturePdfAuthor]
|
||||
description = "Extract author field from PDF documents during processing. Requires PDF parsing, increases latency. Independent of audit level."
|
||||
label = "Capture PDF Author Metadata"
|
||||
|
||||
[admin.settings.security.audit.captureOperationResults]
|
||||
description = "Capture method return values. Not recommended: significantly increases log volume and disk usage."
|
||||
label = "Capture Operation Results"
|
||||
|
||||
[admin.settings.security.csrfDisabled]
|
||||
description = "Disable Cross-Site Request Forgery protection (not recommended)"
|
||||
label = "Disable CSRF Protection"
|
||||
@@ -1540,72 +1564,152 @@ tags = "attachments,add,remove,embed,file"
|
||||
title = "Add Attachments"
|
||||
|
||||
[audit]
|
||||
configureAudit = "Configure Audit Logging"
|
||||
configureAuditMessage = "Adjust audit logging level, retention period, and other settings in the Security & Authentication section."
|
||||
disabled = "Audit logging is disabled"
|
||||
disabledMessage = "Enable audit logging in your application configuration to track system events."
|
||||
enterpriseRequired = "Enterprise License Required"
|
||||
enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics."
|
||||
goToSettings = "Go to Audit Settings"
|
||||
notAvailable = "Audit system not available"
|
||||
notAvailableMessage = "The audit system is not configured or not available."
|
||||
|
||||
[audit.charts]
|
||||
byType = "Events by Type"
|
||||
byUser = "Events by User"
|
||||
day = "Day"
|
||||
error = "Error loading charts"
|
||||
month = "Month"
|
||||
overTime = "Events Over Time"
|
||||
title = "Audit Dashboard"
|
||||
week = "Week"
|
||||
|
||||
[audit.error]
|
||||
title = "Error loading audit system"
|
||||
|
||||
[audit.events]
|
||||
actions = "Actions"
|
||||
clearFilters = "Clear"
|
||||
details = "Details"
|
||||
endDate = "End date"
|
||||
error = "Error loading events"
|
||||
eventDetails = "Event Details"
|
||||
filterByType = "Filter by type"
|
||||
filterByUser = "Filter by user"
|
||||
ipAddress = "IP Address"
|
||||
noEvents = "No events found"
|
||||
startDate = "Start date"
|
||||
timestamp = "Timestamp"
|
||||
title = "Audit Events"
|
||||
type = "Type"
|
||||
user = "User"
|
||||
viewDetails = "View Details"
|
||||
|
||||
[audit.export]
|
||||
clearFilters = "Clear"
|
||||
description = "Export audit events to CSV or JSON format. Use filters to limit the exported data."
|
||||
endDate = "End date"
|
||||
error = "Failed to export data"
|
||||
exportButton = "Export Data"
|
||||
fieldAuthor = "Author (from PDF)"
|
||||
fieldDate = "Date"
|
||||
fieldDocumentName = "Document Name"
|
||||
fieldFileHash = "File Hash (SHA-256)"
|
||||
fieldIpAddress = "IP Address"
|
||||
fieldOperationResults = "Operation Results"
|
||||
fieldOutcome = "Outcome (Success/Failure)"
|
||||
fieldTool = "Tool"
|
||||
fieldUsername = "Username"
|
||||
filterByType = "Filter by type"
|
||||
filterByUser = "Filter by user"
|
||||
filters = "Filters (Optional)"
|
||||
format = "Export Format"
|
||||
selectFields = "Select Fields to Include"
|
||||
startDate = "Start date"
|
||||
title = "Export Audit Data"
|
||||
verboseRequired = "Requires VERBOSE audit level"
|
||||
|
||||
[audit.systemStatus]
|
||||
autoRefresh = "Auto-refresh"
|
||||
autoRefreshLabel = "Auto-refresh every 30s"
|
||||
captureBySettings = "Enable in settings"
|
||||
capturedFields = "Captured Fields"
|
||||
date = "Date"
|
||||
days = "days"
|
||||
disabled = "Disabled"
|
||||
documentName = "Document Name"
|
||||
enabled = "Enabled"
|
||||
fileHash = "File Hash"
|
||||
level = "Audit Level"
|
||||
pdfAuthor = "PDF Author"
|
||||
retention = "Retention Period"
|
||||
status = "Audit Logging"
|
||||
title = "System Status"
|
||||
tool = "Tool"
|
||||
totalEvents = "Total Events"
|
||||
username = "Username"
|
||||
verboseOnly = "VERBOSE only"
|
||||
|
||||
[audit.stats]
|
||||
activeUsers = "Active Users"
|
||||
attention = "Attention needed"
|
||||
avgLatency = "Avg Latency"
|
||||
error = "Error loading statistics"
|
||||
errorLoadingStats = "Failed to load statistics"
|
||||
excellent = "Excellent"
|
||||
good = "Good"
|
||||
noData = "N/A"
|
||||
successRate = "Success Rate"
|
||||
title = "Summary"
|
||||
totalEvents = "Total Events"
|
||||
vsLastPeriod = "vs last period"
|
||||
|
||||
[audit.charts]
|
||||
byTool = "Top Tools Used"
|
||||
byType = "Events by Type"
|
||||
byUser = "Top Users"
|
||||
day = "Day"
|
||||
error = "Error loading charts"
|
||||
hourlyActivity = "Hourly Activity"
|
||||
month = "Month"
|
||||
noData = "No data for this period"
|
||||
overTime = "Events Over Time"
|
||||
title = "Audit Dashboard"
|
||||
week = "Week"
|
||||
|
||||
[audit.events]
|
||||
actions = "Actions"
|
||||
author = "Author"
|
||||
clearFilters = "Clear"
|
||||
details = "Details"
|
||||
documentName = "Document"
|
||||
endDate = "End date"
|
||||
error = "Error loading events"
|
||||
eventDetails = "Event Details"
|
||||
failure = "Failure"
|
||||
fileHash = "File Hash"
|
||||
filterByType = "Filter by type"
|
||||
filterByUser = "Filter by user"
|
||||
ipAddress = "IP Address"
|
||||
noEvents = "No events found"
|
||||
outcome = "Status"
|
||||
sortAsc = "Sort ascending"
|
||||
sortDesc = "Sort descending"
|
||||
startDate = "Start date"
|
||||
success = "Success"
|
||||
timestamp = "Timestamp"
|
||||
title = "Audit Events"
|
||||
type = "Type"
|
||||
user = "User"
|
||||
viewDetails = "View Details"
|
||||
|
||||
[audit.filters]
|
||||
allOutcomes = "All"
|
||||
failureOnly = "Failures only"
|
||||
last7Days = "Last 7 days"
|
||||
last30Days = "Last 30 days"
|
||||
outcomeFilter = "Outcome"
|
||||
quickPresets = "Quick filters"
|
||||
successOnly = "Success only"
|
||||
thisMonth = "This month"
|
||||
today = "Today"
|
||||
|
||||
[audit.tabs]
|
||||
clearData = "Clear Data"
|
||||
dashboard = "Dashboard"
|
||||
events = "Audit Events"
|
||||
export = "Export"
|
||||
|
||||
[audit.clearData]
|
||||
cancel = "Cancel"
|
||||
codeDoesNotMatch = "Code does not match"
|
||||
codePlaceholder = "Type the code here"
|
||||
confirmationCode = "Confirmation Code"
|
||||
confirmationRequired = "Delete All Audit Data"
|
||||
confirmMessage = "This will permanently remove all audit logs. Enter the confirmation code below to proceed."
|
||||
confirmTitle = "Please confirm you want to delete"
|
||||
deleteButton = "Delete"
|
||||
enterCode = "Confirmation Code"
|
||||
enterCodeBelow = "Enter the code exactly as shown above (case-sensitive)"
|
||||
initiateDelete = "Delete All Data"
|
||||
irreversible = "IRREVERSIBLE"
|
||||
success = "Success"
|
||||
successMessage = "All audit data has been cleared successfully"
|
||||
warning1 = "This action cannot be undone"
|
||||
warning2 = "Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database."
|
||||
|
||||
[auth]
|
||||
accessDenied = "Access Denied"
|
||||
insufficientPermissions = "You do not have permission to perform this action."
|
||||
@@ -6539,9 +6643,13 @@ tags = "web-capture,save-page,web-to-doc,archive"
|
||||
title = "URL To PDF"
|
||||
|
||||
[usage]
|
||||
aboutUsageAnalytics = "About Usage Analytics"
|
||||
configureSettings = "Configure Analytics Settings"
|
||||
error = "Error loading usage statistics"
|
||||
noData = "No data available"
|
||||
noDataMessage = "No usage statistics are currently available."
|
||||
usageAnalyticsExplanation = "Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events."
|
||||
viewAuditLogs = "View Audit Logs"
|
||||
|
||||
[usage.chart]
|
||||
title = "Endpoint Usage Chart"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
height: 45rem; /* 720px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
height: 45rem; /* 720px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
height: 45rem; /* 720px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -118,7 +118,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
size={isMobile ? "100%" : 1100}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
|
||||
@@ -5,6 +5,10 @@ export interface AuditSystemStatus {
|
||||
level: string;
|
||||
retentionDays: number;
|
||||
totalEvents: number;
|
||||
pdfMetadataEnabled: boolean;
|
||||
captureFileHash: boolean;
|
||||
capturePdfAuthor: boolean;
|
||||
captureOperationResults: boolean;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
@@ -36,12 +40,32 @@ export interface AuditChartsData {
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
eventType?: string;
|
||||
username?: string;
|
||||
eventType?: string | string[];
|
||||
username?: string | string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
outcome?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
fields?: string;
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
totalEvents: number;
|
||||
prevTotalEvents: number;
|
||||
uniqueUsers: number;
|
||||
prevUniqueUsers: number;
|
||||
successRate: number; // 0–100
|
||||
prevSuccessRate: number;
|
||||
avgLatencyMs: number;
|
||||
prevAvgLatencyMs: number;
|
||||
errorCount: number;
|
||||
topEventType: string;
|
||||
topUser: string;
|
||||
eventsByType: Record<string, number>;
|
||||
eventsByUser: Record<string, number>;
|
||||
topTools: Record<string, number>;
|
||||
hourlyDistribution: Record<string, number>; // "00"–"23" keys
|
||||
}
|
||||
|
||||
const auditService = {
|
||||
@@ -60,9 +84,23 @@ const auditService = {
|
||||
level: data.auditLevel,
|
||||
retentionDays: data.retentionDays,
|
||||
totalEvents: 0, // Will be fetched separately
|
||||
pdfMetadataEnabled: data.pdfMetadataEnabled ?? false,
|
||||
captureFileHash: data.captureFileHash ?? false,
|
||||
capturePdfAuthor: data.capturePdfAuthor ?? false,
|
||||
captureOperationResults: data.captureOperationResults ?? false,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get audit statistics and KPI data
|
||||
*/
|
||||
async getStats(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise<AuditStats> {
|
||||
const response = await apiClient.get<AuditStats>('/api/v1/proprietary/ui-data/audit-stats', {
|
||||
params: { period: timePeriod },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters
|
||||
*/
|
||||
@@ -84,7 +122,7 @@ const auditService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Export audit data
|
||||
* Export audit data with custom field selection
|
||||
*/
|
||||
async exportData(
|
||||
format: 'csv' | 'json',
|
||||
@@ -112,6 +150,13 @@ const auditService = {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all audit data from the database (irreversible)
|
||||
*/
|
||||
async clearAllAuditData(): Promise<void> {
|
||||
await apiClient.post('/api/v1/proprietary/ui-data/audit-clear-all', {});
|
||||
},
|
||||
};
|
||||
|
||||
export default auditService;
|
||||
|
||||
+83
-8
@@ -1,18 +1,23 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Loader, Alert, Stack } from '@mantine/core';
|
||||
import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus';
|
||||
import AuditStatsCards from '@app/components/shared/config/configSections/audit/AuditStatsCards';
|
||||
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
|
||||
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
|
||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
||||
import AuditClearDataSection from '@app/components/shared/config/configSections/audit/AuditClearDataSection';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
const AdminAuditSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const { config } = useAppConfig();
|
||||
const licenseType = config?.license ?? 'NORMAL';
|
||||
@@ -21,6 +26,7 @@ const AdminAuditSection: React.FC = () => {
|
||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
@@ -52,6 +58,10 @@ const AdminAuditSection: React.FC = () => {
|
||||
level: 'INFO',
|
||||
retentionDays: 90,
|
||||
totalEvents: 1234,
|
||||
pdfMetadataEnabled: true,
|
||||
captureFileHash: true,
|
||||
capturePdfAuthor: true,
|
||||
captureOperationResults: false,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -94,6 +104,8 @@ const AdminAuditSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = loginEnabled && hasEnterpriseLicense;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
@@ -101,32 +113,95 @@ const AdminAuditSection: React.FC = () => {
|
||||
show={!hasEnterpriseLicense}
|
||||
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
|
||||
/>
|
||||
|
||||
{/* Info banner about audit settings */}
|
||||
{isEnabled && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||
title={t('audit.configureAudit', 'Configure Audit Logging')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'audit.configureAuditMessage',
|
||||
'Adjust audit logging level, retention period, and other settings in the Security & Authentication section.'
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
|
||||
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||
>
|
||||
{t('audit.goToSettings', 'Go to Audit Settings')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus.enabled ? (
|
||||
{systemStatus?.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
|
||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
<Tabs.Tab value="events" disabled={!isEnabled}>
|
||||
{t('audit.tabs.events', 'Audit Events')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
<Tabs.Tab value="export" disabled={!isEnabled}>
|
||||
{t('audit.tabs.export', 'Export')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="clearData" disabled={!isEnabled}>
|
||||
{t('audit.tabs.clearData', 'Clear Data')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<AuditChartsSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
<Stack gap="lg">
|
||||
{/* Stats Cards - Always Visible */}
|
||||
<AuditStatsCards loginEnabled={isEnabled} timePeriod={timePeriod} />
|
||||
|
||||
{/* Charts in Accordion - Collapsible */}
|
||||
<Accordion defaultValue={["events-over-time"]} multiple>
|
||||
<Accordion.Item value="events-over-time">
|
||||
<Accordion.Control>
|
||||
{t('audit.charts.overTime', 'Events Over Time')}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<AuditChartsSection
|
||||
loginEnabled={isEnabled}
|
||||
timePeriod={timePeriod}
|
||||
onTimePeriodChange={setTimePeriod}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
<AuditEventsTable
|
||||
loginEnabled={isEnabled}
|
||||
captureFileHash={systemStatus?.captureFileHash}
|
||||
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
<AuditExportSection
|
||||
loginEnabled={isEnabled}
|
||||
captureFileHash={systemStatus?.captureFileHash}
|
||||
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||
captureOperationResults={systemStatus?.captureOperationResults}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="clearData" pt="md">
|
||||
<AuditClearDataSection loginEnabled={isEnabled} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
||||
+65
-1
@@ -33,6 +33,9 @@ interface SecuritySettingsData {
|
||||
enabled?: boolean;
|
||||
level?: number;
|
||||
retentionDays?: number;
|
||||
captureFileHash?: boolean;
|
||||
capturePdfAuthor?: boolean;
|
||||
captureOperationResults?: boolean;
|
||||
};
|
||||
html?: {
|
||||
urlSecurity?: {
|
||||
@@ -144,7 +147,10 @@ export default function AdminSecuritySection() {
|
||||
// Premium audit settings
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
|
||||
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays,
|
||||
'premium.enterpriseFeatures.audit.captureFileHash': audit?.captureFileHash,
|
||||
'premium.enterpriseFeatures.audit.capturePdfAuthor': audit?.capturePdfAuthor,
|
||||
'premium.enterpriseFeatures.audit.captureOperationResults': audit?.captureOperationResults
|
||||
};
|
||||
|
||||
// System HTML settings
|
||||
@@ -553,6 +559,64 @@ export default function AdminSecuritySection() {
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert color="yellow" icon={<LocalIcon icon="info" />} title={t('admin.settings.security.audit.advancedOptions.title', 'Advanced Options')}>
|
||||
{t('admin.settings.security.audit.advancedOptions.description', 'The following options increase processing time and memory usage. Enable only if truly needed.')}
|
||||
</Alert>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureFileHash.label', 'Capture File Hash')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.audit.captureFileHash.description', 'Store MD5 hash of processed files for audit trail verification')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
name="audit_captureFileHash"
|
||||
checked={settings?.audit?.captureFileHash || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureFileHash: e.target.checked } })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('audit.captureFileHash')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.audit.capturePdfAuthor.label', 'Capture PDF Author')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.audit.capturePdfAuthor.description', 'Extract author field from PDF documents during processing')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
name="audit_capturePdfAuthor"
|
||||
checked={settings?.audit?.capturePdfAuthor || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, capturePdfAuthor: e.target.checked } })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('audit.capturePdfAuthor')} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureOperationResults.label', 'Capture Operation Results')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.audit.captureOperationResults.description', 'Store output file information and processing results in audit logs')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
name="audit_captureOperationResults"
|
||||
checked={settings?.audit?.captureOperationResults || false}
|
||||
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureOperationResults: e.target.checked } })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('audit.captureOperationResults')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
+41
-11
@@ -1,15 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Card,
|
||||
} from '@mantine/core';
|
||||
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
||||
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
|
||||
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
|
||||
@@ -21,6 +13,7 @@ import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRe
|
||||
|
||||
const AdminUsageSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const { config } = useAppConfig();
|
||||
const licenseType = config?.license ?? 'NORMAL';
|
||||
@@ -30,7 +23,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('api');
|
||||
|
||||
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
|
||||
const totalVisits = 15847;
|
||||
@@ -180,6 +173,43 @@ const AdminUsageSection: React.FC = () => {
|
||||
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
|
||||
/>
|
||||
|
||||
{/* Info banner about usage analytics and audit relationship */}
|
||||
{loginEnabled && hasEnterpriseLicense && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||
title={t('usage.aboutUsageAnalytics', 'About Usage Analytics')}
|
||||
color="cyan"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'usage.usageAnalyticsExplanation',
|
||||
'Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.'
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate('/settings/adminSecurity')}
|
||||
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||
>
|
||||
{t('usage.configureSettings', 'Configure Analytics Settings')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
|
||||
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||
>
|
||||
{t('usage.viewAuditLogs', 'View Audit Logs')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
|
||||
+191
-98
@@ -1,65 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from '@mantine/core';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditChartsData } from '@app/services/auditService';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
// Event type color mapping
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
USER_LOGIN: 'var(--mantine-color-green-6)',
|
||||
USER_LOGOUT: 'var(--mantine-color-gray-5)',
|
||||
USER_FAILED_LOGIN: 'var(--mantine-color-red-6)',
|
||||
USER_PROFILE_UPDATE: 'var(--mantine-color-blue-6)',
|
||||
SETTINGS_CHANGED: 'var(--mantine-color-orange-6)',
|
||||
FILE_OPERATION: 'var(--mantine-color-cyan-6)',
|
||||
PDF_PROCESS: 'var(--mantine-color-violet-6)',
|
||||
UI_DATA: 'var(--mantine-color-teal-6)',
|
||||
HTTP_REQUEST: 'var(--mantine-color-indigo-6)',
|
||||
};
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '0.5rem',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: `var(--mantine-color-${color}-6)`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
const getEventTypeColor = (type: string): string => {
|
||||
return EVENT_TYPE_COLORS[type] || 'var(--mantine-color-blue-6)';
|
||||
};
|
||||
|
||||
interface AuditChartsSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
timePeriod?: 'day' | 'week' | 'month';
|
||||
onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void;
|
||||
}
|
||||
|
||||
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
|
||||
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
|
||||
loginEnabled = true,
|
||||
timePeriod = 'week',
|
||||
onTimePeriodChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -72,7 +66,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
||||
const data = await auditService.getChartsData(timePeriod);
|
||||
setChartsData(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load charts');
|
||||
setError(err instanceof Error ? err.message : t('audit.charts.error', 'Failed to load charts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -81,7 +75,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
||||
if (loginEnabled) {
|
||||
fetchChartsData();
|
||||
} else {
|
||||
// Provide example charts data when login is disabled
|
||||
// Demo data when login disabled
|
||||
setChartsData({
|
||||
eventsByType: {
|
||||
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
|
||||
@@ -122,62 +116,161 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform data for Recharts
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
||||
name: label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}));
|
||||
|
||||
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
|
||||
label,
|
||||
type: label,
|
||||
value: chartsData.eventsByType.values[index],
|
||||
}));
|
||||
|
||||
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
||||
label,
|
||||
user: label,
|
||||
value: chartsData.eventsByUser.values[index],
|
||||
}));
|
||||
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.charts.title', 'Audit Dashboard')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setTimePeriod(value as 'day' | 'week' | 'month');
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
data={[
|
||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{/* Header with time period selector */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.charts.title', 'Audit Dashboard')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => {
|
||||
onTimePeriodChange?.(value as 'day' | 'week' | 'month');
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
data={[
|
||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing="xl">
|
||||
<SimpleBarChart
|
||||
data={eventsByTypeData}
|
||||
title={t('audit.charts.byType', 'Events by Type')}
|
||||
color="blue"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsByUserData}
|
||||
title={t('audit.charts.byUser', 'Events by User')}
|
||||
color="green"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsOverTimeData}
|
||||
title={t('audit.charts.overTime', 'Events Over Time')}
|
||||
color="purple"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
{/* Full-width Events Over Time Chart */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t('audit.charts.overTime', 'Events Over Time')}
|
||||
</Text>
|
||||
<Box style={{ width: '100%', height: 280 }}>
|
||||
{eventsOverTimeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={eventsOverTimeData}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
|
||||
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--mantine-color-blue-6)"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorValue)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">{t('audit.charts.noData', 'No data for this period')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Two-column grid for remaining charts */}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
{/* Events by Type Chart */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t('audit.charts.byType', 'Events by Type')}
|
||||
</Text>
|
||||
<Box style={{ width: '100%', height: 280 }}>
|
||||
{eventsByTypeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={eventsByTypeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="type" stroke="var(--mantine-color-gray-6)" angle={-45} textAnchor="end" height={80} />
|
||||
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
|
||||
{eventsByTypeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getEventTypeColor(entry.type)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Top Users Chart (Horizontal) */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t('audit.charts.byUser', 'Top Users')}
|
||||
</Text>
|
||||
<Box style={{ width: '100%', height: 280 }}>
|
||||
{eventsByUserData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={eventsByUserData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
|
||||
<YAxis type="category" dataKey="user" stroke="var(--mantine-color-gray-6)" width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService from '@app/services/auditService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface AuditClearDataSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnabled = true }) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [generatedCode, setGeneratedCode] = useState('');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleInitiateDeletion = () => {
|
||||
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||
setGeneratedCode(code);
|
||||
setConfirmationCode('');
|
||||
setShowConfirmation(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setConfirmationCode('');
|
||||
setGeneratedCode('');
|
||||
setShowConfirmation(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClearData = async () => {
|
||||
if (confirmationCode !== generatedCode) {
|
||||
setError(t('audit.clearData.codeDoesNotMatch', 'Code does not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
await auditService.clearAllAuditData();
|
||||
setSuccess(true);
|
||||
resetForm();
|
||||
// Auto-dismiss success message after 5 seconds
|
||||
setTimeout(() => setSuccess(false), 5000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to clear audit data');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="green"
|
||||
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
|
||||
title={t('audit.clearData.success', 'Success')}
|
||||
onClose={() => setSuccess(false)}
|
||||
closeButtonLabel="Close alert"
|
||||
withCloseButton
|
||||
>
|
||||
{t('audit.clearData.successMessage', 'All audit data has been cleared successfully')}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||
title={t('audit.clearData.confirmTitle', 'Please confirm you want to delete')}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('audit.clearData.confirmMessage', 'This will permanently remove all audit logs. Enter the confirmation code below to proceed.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
|
||||
<Stack gap="md">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed" mb="xs">
|
||||
{t('audit.clearData.confirmationCode', 'Confirmation Code')}
|
||||
</Text>
|
||||
<Code
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{generatedCode}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t('audit.clearData.enterCodeBelow', 'Enter the code exactly as shown above (case-sensitive)')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label={t('audit.clearData.enterCode', 'Confirmation Code')}
|
||||
placeholder={t('audit.clearData.codePlaceholder', 'Type the code here')}
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
|
||||
disabled={!loginEnabled}
|
||||
error={
|
||||
confirmationCode && confirmationCode !== generatedCode
|
||||
? t('audit.clearData.codeDoesNotMatch', 'Code does not match')
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="default" onClick={resetForm} disabled={clearing}>
|
||||
{t('audit.clearData.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleClearData}
|
||||
loading={clearing}
|
||||
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
|
||||
>
|
||||
{t('audit.clearData.deleteButton', 'Delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||
title={t('audit.clearData.warning1', 'This action cannot be undone')}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('audit.clearData.warning2', 'Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Text fw={600}>
|
||||
{t('audit.clearData.confirmationRequired', 'Delete All Audit Data')}
|
||||
</Text>
|
||||
<Badge color="red">
|
||||
{t('audit.clearData.irreversible', 'IRREVERSIBLE')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleInitiateDeletion}
|
||||
disabled={!loginEnabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('audit.clearData.initiateDelete', 'Delete All Data')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditClearDataSection;
|
||||
+179
-41
@@ -11,18 +11,27 @@ import {
|
||||
Loader,
|
||||
Alert,
|
||||
Table,
|
||||
Badge,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditEvent } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface AuditEventsTableProps {
|
||||
loginEnabled?: boolean;
|
||||
captureFileHash?: boolean;
|
||||
capturePdfAuthor?: boolean;
|
||||
}
|
||||
|
||||
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
|
||||
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
|
||||
loginEnabled = true,
|
||||
captureFileHash = false,
|
||||
capturePdfAuthor = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
@@ -30,6 +39,11 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
||||
const [sortKey, setSortKey] = useState<'timestamp' | 'eventType' | 'username' | 'ipAddress' | null>('timestamp');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const showAuthor = capturePdfAuthor;
|
||||
const showFileHash = captureFileHash;
|
||||
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
|
||||
@@ -122,6 +136,69 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Sort handling
|
||||
const toggleSort = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
|
||||
if (sortKey !== key) return 'unfold-more';
|
||||
return sortDir === 'asc' ? 'expand-less' : 'expand-more';
|
||||
};
|
||||
|
||||
// Event type colors
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
USER_LOGIN: 'green',
|
||||
USER_LOGOUT: 'gray',
|
||||
USER_FAILED_LOGIN: 'red',
|
||||
USER_PROFILE_UPDATE: 'blue',
|
||||
SETTINGS_CHANGED: 'orange',
|
||||
FILE_OPERATION: 'cyan',
|
||||
PDF_PROCESS: 'violet',
|
||||
UI_DATA: 'gray',
|
||||
HTTP_REQUEST: 'indigo',
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string): string => {
|
||||
return EVENT_TYPE_COLORS[type] || 'blue';
|
||||
};
|
||||
|
||||
// Apply sorting to current events
|
||||
const sortedEvents = [...events].sort((a, b) => {
|
||||
let aVal: any;
|
||||
let bVal: any;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'timestamp':
|
||||
aVal = new Date(a.timestamp).getTime();
|
||||
bVal = new Date(b.timestamp).getTime();
|
||||
break;
|
||||
case 'eventType':
|
||||
aVal = a.eventType;
|
||||
bVal = b.eventType;
|
||||
break;
|
||||
case 'username':
|
||||
aVal = a.username;
|
||||
bVal = b.username;
|
||||
break;
|
||||
case 'ipAddress':
|
||||
aVal = a.ipAddress;
|
||||
bVal = b.ipAddress;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
@@ -150,6 +227,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: 'auto', overflowY: 'hidden', marginBottom: '1rem' }}>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
@@ -161,59 +239,118 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||
<UnstyledButton onClick={() => toggleSort('timestamp')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
<LocalIcon icon={getSortIcon('timestamp')} width="0.9rem" height="0.9rem" />
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||
<UnstyledButton onClick={() => toggleSort('eventType')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('audit.events.type', 'Type')}
|
||||
<LocalIcon icon={getSortIcon('eventType')} width="0.9rem" height="0.9rem" />
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||
<UnstyledButton onClick={() => toggleSort('username')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('audit.events.user', 'User')}
|
||||
<LocalIcon icon={getSortIcon('username')} width="0.9rem" height="0.9rem" />
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
{t('audit.events.documentName', 'Document Name')}
|
||||
</Table.Th>
|
||||
{showAuthor && (
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.author', 'Author')}
|
||||
</Table.Th>
|
||||
)}
|
||||
{showFileHash && (
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('audit.events.fileHash', 'File Hash')}
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
|
||||
{t('audit.events.actions', 'Actions')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{events.length === 0 ? (
|
||||
{sortedEvents.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('audit.events.noEvents', 'No events found')}
|
||||
</Text>
|
||||
<Table.Td colSpan={totalColumns}>
|
||||
<Group justify="center" py="xl">
|
||||
<Stack align="center" gap={0}>
|
||||
<LocalIcon icon="search" width="2rem" height="2rem" style={{ opacity: 0.4 }} />
|
||||
<Text ta="center" c="dimmed" size="sm">
|
||||
{t('audit.events.noEvents', 'No events found')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Table.Tr key={event.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.eventType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.ipAddress}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('audit.events.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
sortedEvents.map((event) => {
|
||||
// Extract document name, author, hash from details.files if available
|
||||
let documentName = '';
|
||||
let author = '';
|
||||
let fileHash = '';
|
||||
if (event.details && typeof event.details === 'object') {
|
||||
const details = event.details as Record<string, any>;
|
||||
const files = details.files;
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
const firstFile = files[0] as Record<string, any>;
|
||||
documentName = firstFile.name || '';
|
||||
if (showAuthor || showFileHash) {
|
||||
author = firstFile.pdfAuthor || '';
|
||||
fileHash = firstFile.fileHash ? firstFile.fileHash.substring(0, 16) + '...' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Tr key={event.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
|
||||
{event.eventType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" title={documentName}>
|
||||
{documentName || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{showAuthor && (
|
||||
<Table.Td>
|
||||
<Text size="sm">{author}</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
{showFileHash && (
|
||||
<Table.Td>
|
||||
<Text size="sm" title={fileHash} style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{fileHash}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td ta="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('audit.events.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -228,6 +365,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
+93
-2
@@ -6,6 +6,7 @@ import {
|
||||
Stack,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Checkbox,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService from '@app/services/auditService';
|
||||
@@ -15,12 +16,31 @@ import AuditFiltersForm from '@app/components/shared/config/configSections/audit
|
||||
|
||||
interface AuditExportSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
captureFileHash?: boolean;
|
||||
capturePdfAuthor?: boolean;
|
||||
captureOperationResults?: boolean;
|
||||
}
|
||||
|
||||
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
|
||||
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
|
||||
loginEnabled = true,
|
||||
captureFileHash = false,
|
||||
capturePdfAuthor = false,
|
||||
captureOperationResults = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>({
|
||||
date: true,
|
||||
username: true,
|
||||
ipaddress: false,
|
||||
tool: true,
|
||||
documentName: true,
|
||||
outcome: true,
|
||||
author: capturePdfAuthor,
|
||||
fileHash: captureFileHash,
|
||||
operationResults: captureOperationResults,
|
||||
});
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled);
|
||||
@@ -31,7 +51,9 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, filters);
|
||||
const fieldsParam = Object.keys(selectedFields).filter(k => selectedFields[k as keyof typeof selectedFields]).join(',');
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, { ...filters, fields: fieldsParam });
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -83,6 +105,75 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.selectFields', 'Select Fields to Include')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldDate', 'Date')}
|
||||
checked={selectedFields.date}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldUsername', 'Username')}
|
||||
checked={selectedFields.username}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldIpAddress', 'IP Address')}
|
||||
checked={selectedFields.ipaddress}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldTool', 'Tool')}
|
||||
checked={selectedFields.tool}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldDocumentName', 'Document Name')}
|
||||
checked={selectedFields.documentName}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldOutcome', 'Outcome (Success/Failure)')}
|
||||
checked={selectedFields.outcome}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
{capturePdfAuthor && (
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldAuthor', 'Author (from PDF)')}
|
||||
checked={selectedFields.author}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
{captureFileHash && (
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldFileHash', 'File Hash (SHA-256)')}
|
||||
checked={selectedFields.fileHash}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
{captureOperationResults && (
|
||||
<Checkbox
|
||||
label={t('audit.export.fieldOperationResults', 'Operation Results')}
|
||||
checked={selectedFields.operationResults}
|
||||
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
|
||||
+146
-49
@@ -1,10 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Group, Select, Button } from '@mantine/core';
|
||||
import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditFilters } from '@app/services/auditService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
|
||||
const formatDateToYMD = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Helper to calculate date range for quick presets
|
||||
const getDateRange = (preset: string): [Date, Date] | null => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return [start, end];
|
||||
case 'last7':
|
||||
start.setDate(start.getDate() - 6);
|
||||
return [start, end];
|
||||
case 'last30':
|
||||
start.setDate(start.getDate() - 29);
|
||||
return [start, end];
|
||||
case 'thisMonth':
|
||||
start.setDate(1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return [start, end];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface AuditFiltersFormProps {
|
||||
filters: AuditFilters;
|
||||
eventTypes: string[];
|
||||
@@ -27,55 +61,118 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleQuickPreset = (preset: string) => {
|
||||
const range = getDateRange(preset);
|
||||
if (range) {
|
||||
const [start, end] = range;
|
||||
onFilterChange('startDate', formatDateToYMD(start));
|
||||
onFilterChange('endDate', formatDateToYMD(end));
|
||||
}
|
||||
};
|
||||
|
||||
const isPresetActive = (preset: string): boolean => {
|
||||
if (!filters.startDate || !filters.endDate) return false;
|
||||
const range = getDateRange(preset);
|
||||
if (!range) return false;
|
||||
const [expectedStart, expectedEnd] = range;
|
||||
const expectedStartStr = formatDateToYMD(expectedStart);
|
||||
const expectedEndStr = formatDateToYMD(expectedEnd);
|
||||
return filters.startDate === expectedStartStr && filters.endDate === expectedEndStr;
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={filters.eventType}
|
||||
onChange={(value) => onFilterChange('eventType', value || undefined)}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={filters.username}
|
||||
onChange={(value) => onFilterChange('username', value || undefined)}
|
||||
clearable
|
||||
searchable
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('startDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value: string | null) =>
|
||||
onFilterChange('endDate', value ?? undefined)
|
||||
}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
|
||||
{t('audit.events.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{/* Quick Preset Buttons */}
|
||||
<div>
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
{t('audit.filters.quickPresets', 'Quick filters')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant={isPresetActive('today') ? 'filled' : 'light'}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset('today')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('audit.filters.today', 'Today')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive('last7') ? 'filled' : 'light'}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset('last7')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('audit.filters.last7Days', 'Last 7 days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive('last30') ? 'filled' : 'light'}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset('last30')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('audit.filters.last30Days', 'Last 30 days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive('thisMonth') ? 'filled' : 'light'}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset('thisMonth')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('audit.filters.thisMonth', 'This month')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Filter Inputs */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
|
||||
<MultiSelect
|
||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={Array.isArray(filters.eventType) ? filters.eventType : (filters.eventType ? [filters.eventType] : [])}
|
||||
onChange={(value) => onFilterChange('eventType', value.length > 0 ? value : undefined)}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<MultiSelect
|
||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={Array.isArray(filters.username) ? filters.username : (filters.username ? [filters.username] : [])}
|
||||
onChange={(value) => onFilterChange('username', value.length > 0 ? value : undefined)}
|
||||
clearable
|
||||
searchable
|
||||
disabled={disabled}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value: any) => {
|
||||
onFilterChange('startDate', value ? formatDateToYMD(value as Date) : undefined);
|
||||
}}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value: any) => {
|
||||
onFilterChange('endDate', value ? formatDateToYMD(value as Date) : undefined);
|
||||
}}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Clear Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="outline" size="sm" onClick={onClearFilters} disabled={disabled}>
|
||||
{t('audit.events.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditStats } from '@app/services/auditService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface AuditStatsCardsProps {
|
||||
loginEnabled?: boolean;
|
||||
timePeriod: 'day' | 'week' | 'month';
|
||||
}
|
||||
|
||||
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = 'week' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getStats(timePeriod);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled) {
|
||||
fetchStats();
|
||||
} else {
|
||||
// Demo data when login disabled
|
||||
setStats({
|
||||
totalEvents: 4256,
|
||||
prevTotalEvents: 3891,
|
||||
uniqueUsers: 12,
|
||||
prevUniqueUsers: 10,
|
||||
successRate: 96.5,
|
||||
prevSuccessRate: 94.2,
|
||||
avgLatencyMs: 342,
|
||||
prevAvgLatencyMs: 385,
|
||||
errorCount: 148,
|
||||
topEventType: 'PDF_PROCESS',
|
||||
topUser: 'admin',
|
||||
eventsByType: {},
|
||||
eventsByUser: {},
|
||||
topTools: {},
|
||||
hourlyDistribution: {},
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timePeriod, loginEnabled]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.stats.error', 'Error loading statistics')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trendPercent = stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0;
|
||||
const userTrend = stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0;
|
||||
const latencyTrend = stats.prevAvgLatencyMs > 0 ? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) / stats.prevAvgLatencyMs) * 100 : 0;
|
||||
const successTrend = stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
|
||||
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 95) return 'green';
|
||||
if (rate >= 80) return 'yellow';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
|
||||
if (lowerIsBetter) {
|
||||
return trend <= 0 ? 'green' : 'red';
|
||||
}
|
||||
return trend >= 0 ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
|
||||
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
|
||||
return isPositive ? 'trending-up' : 'trending-down';
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
{/* Total Events Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.stats.totalEvents', 'Total Events')}
|
||||
</Text>
|
||||
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
{trendPercent !== 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(trendPercent)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(trendPercent)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(trendPercent).toFixed(1)}% {t('audit.stats.vsLastPeriod', 'vs last period')}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Success Rate Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.stats.successRate', 'Success Rate')}
|
||||
</Text>
|
||||
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.successRate.toFixed(1)}%
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={getSuccessRateColor(stats.successRate)} variant="light" size="sm">
|
||||
{stats.successRate >= 95
|
||||
? t('audit.stats.excellent', 'Excellent')
|
||||
: stats.successRate >= 80
|
||||
? t('audit.stats.good', 'Good')
|
||||
: t('audit.stats.attention', 'Attention needed')}
|
||||
</Badge>
|
||||
{successTrend !== 0 && (
|
||||
<Badge color={getTrendColor(successTrend)} variant="light" size="xs">
|
||||
{successTrend > 0 ? '+' : ''}{successTrend.toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Active Users Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.stats.activeUsers', 'Active Users')}
|
||||
</Text>
|
||||
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.uniqueUsers}
|
||||
</Text>
|
||||
{userTrend !== 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(userTrend)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(userTrend)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(userTrend).toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Avg Latency Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.stats.avgLatency', 'Avg Latency')}
|
||||
</Text>
|
||||
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t('audit.stats.noData', 'N/A')}
|
||||
</Text>
|
||||
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(latencyTrend, true)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(latencyTrend, true)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(latencyTrend).toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditStatsCards;
|
||||
+39
-1
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Card, Group, Stack, Badge, Text } from '@mantine/core';
|
||||
import { Card, Group, Stack, Badge, Text, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
|
||||
@@ -56,6 +56,44 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.systemStatus.capturedFields', 'Captured Fields')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t('audit.systemStatus.username', 'Username')}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t('audit.systemStatus.documentName', 'Document Name')}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t('audit.systemStatus.tool', 'Tool')}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t('audit.systemStatus.date', 'Date')}
|
||||
</Badge>
|
||||
<Badge color={status.capturePdfAuthor ? 'green' : 'gray'} variant="light" size="sm">
|
||||
{t('audit.systemStatus.pdfAuthor', 'PDF Author')}
|
||||
{!status.capturePdfAuthor && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge color={status.captureFileHash ? 'green' : 'gray'} variant="light" size="sm">
|
||||
{t('audit.systemStatus.fileHash', 'File Hash')}
|
||||
{!status.captureFileHash && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user