diff --git a/frontend/index.html b/frontend/index.html index e4d6fbd..5ab31c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,10 @@ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 320b7a5..146f971 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,9 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "docx": "^9.5.1", + "html2canvas": "^1.4.1", + "jspdf": "^4.0.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^6.20.0", @@ -1513,6 +1516,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -1540,6 +1565,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -1953,6 +1985,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2087,6 +2128,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2207,6 +2268,24 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2222,6 +2301,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2458,6 +2546,41 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz", + "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.0.1", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2468,6 +2591,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2863,6 +2996,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2873,6 +3017,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3218,6 +3368,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3230,6 +3390,19 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3240,6 +3413,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3283,7 +3462,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internmap": { @@ -3295,6 +3473,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3367,6 +3551,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3450,6 +3640,41 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", + "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3474,6 +3699,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3609,6 +3843,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -3766,6 +4006,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3826,6 +4072,13 @@ "node": ">=8" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4039,6 +4292,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4093,6 +4352,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -4203,6 +4472,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4248,6 +4532,13 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4290,6 +4581,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4373,6 +4674,21 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4392,6 +4708,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4435,6 +4757,25 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4510,6 +4851,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -4558,6 +4909,15 @@ "node": ">=14.0.0" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4715,6 +5075,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4769,9 +5135,17 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -4933,6 +5307,24 @@ "dev": true, "license": "ISC" }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff8815d..fb05235 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,9 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "docx": "^9.5.1", + "html2canvas": "^1.4.1", + "jspdf": "^4.0.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^6.20.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69a1799..0b4e9d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import MonitoringPage from '@/pages/Monitoring' import ObjectStoragePage from '@/pages/ObjectStorage' import SnapshotReplicationPage from '@/pages/SnapshotReplication' import ShareShieldPage from '@/pages/ShareShield' +import ExecutiveDashboardPage from '@/pages/ExecutiveDashboard' import Layout from '@/components/Layout' // Create a client @@ -80,6 +81,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index df82be1..913982e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,7 +18,8 @@ import { Camera, Shield, ShieldCheck, - ChevronRight + ChevronRight, + FileBarChart } from 'lucide-react' import { useState, useEffect } from 'react' @@ -80,6 +81,7 @@ export default function Layout() { { name: 'Storage', href: '/backup?tab=storage', icon: HardDrive }, { name: 'Console', href: '/backup?tab=console', icon: Terminal }, ] + const navigation = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, @@ -96,6 +98,7 @@ export default function Layout() { { name: 'Monitoring & Logs', href: '/monitoring', icon: Activity }, { name: 'Alerts', href: '/alerts', icon: Bell }, { name: 'System', href: '/system', icon: Server }, + { name: 'Reporting', href: '/reporting', icon: FileBarChart }, ] if (user?.roles.includes('admin')) { diff --git a/frontend/src/pages/BackupManagement.tsx b/frontend/src/pages/BackupManagement.tsx index 60163f4..99f21cf 100644 --- a/frontend/src/pages/BackupManagement.tsx +++ b/frontend/src/pages/BackupManagement.tsx @@ -3,6 +3,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useLocation } from 'react-router-dom' import { backupAPI, StoragePool, StorageVolume, CreateStoragePoolRequest } from '@/api/backup' import { X } from 'lucide-react' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts' // Styles for checkbox and tree lines const clientManagementStyles = ` @@ -73,9 +83,9 @@ const clientManagementStyles = ` export default function BackupManagement() { const location = useLocation() const searchParams = new URLSearchParams(location.search) - const tabFromUrl = searchParams.get('tab') as 'dashboard' | 'jobs' | 'clients' | 'storage' | 'filesets' | 'console' | null + const tabFromUrl = searchParams.get('tab') as 'dashboard' | 'jobs' | 'clients' | 'clients-filesystem' | 'clients-database' | 'clients-virtualization' | 'storage' | 'filesets' | 'console' | null - const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'filesets' | 'console'>( + const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'clients-filesystem' | 'clients-database' | 'clients-virtualization' | 'storage' | 'filesets' | 'console'>( tabFromUrl || 'dashboard' ) const [searchQuery, setSearchQuery] = useState('') @@ -504,7 +514,19 @@ export default function BackupManagement() { )} {activeTab === 'clients' && ( - changeTab('console')} /> + + )} + + {activeTab === 'clients-filesystem' && ( + changeTab('console')} /> + )} + + {activeTab === 'clients-database' && ( + changeTab('console')} /> + )} + + {activeTab === 'clients-virtualization' && ( + changeTab('console')} /> )} {activeTab === 'storage' && ( @@ -1351,54 +1373,227 @@ function BackupConsoleTab() { ) } -// Clients Management Tab Component -function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) { - const [searchQuery, setSearchQuery] = useState('') - const [statusFilter, setStatusFilter] = useState('all') - const [categoryFilter, setCategoryFilter] = useState('all') - const [expandedRows, setExpandedRows] = useState>(new Set()) - const [selectAll, setSelectAll] = useState(false) - const [selectedClients, setSelectedClients] = useState>(new Set()) - const [openMenuId, setOpenMenuId] = useState(null) - const [isStartingBackup, setIsStartingBackup] = useState(null) - const queryClient = useQueryClient() +// Legacy Clients Management Tab Component - REMOVED +// Replaced by ClientsDashboardTab and category-specific tabs (FilesystemClientsTab, DatabaseClientsTab, VirtualizationClientsTab) - const { data, isLoading, error } = useQuery({ - queryKey: ['backup-clients', statusFilter, searchQuery, categoryFilter], - queryFn: () => backupAPI.listClients({ - enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled', - search: searchQuery || undefined, - category: categoryFilter === 'all' ? undefined : categoryFilter as 'File' | 'Database' | 'Virtual', - }), +// Clients Dashboard Tab Component +function ClientsDashboardTab() { + const { data: allClientsData } = useQuery({ + queryKey: ['backup-clients-all'], + queryFn: () => backupAPI.listClients(), }) - // Mutation untuk start backup job - const startBackupMutation = useMutation({ - mutationFn: async (clientName: string) => { - // Gunakan bconsole command untuk run backup job - // Format: run job= client= - // Kita akan coba run job dengan nama yang sama dengan client name, atau "Backup-" - const jobName = `Backup-${clientName}` - const command = `run job=${jobName} client=${clientName} yes` - return backupAPI.executeBconsoleCommand(command) - }, - onSuccess: () => { - // Refresh clients list dan jobs list - queryClient.invalidateQueries({ queryKey: ['backup-clients'] }) - queryClient.invalidateQueries({ queryKey: ['backup-jobs'] }) - setIsStartingBackup(null) - }, - onError: (error: any) => { - console.error('Failed to start backup:', error) - setIsStartingBackup(null) - alert(`Failed to start backup: ${error?.response?.data?.details || error.message || 'Unknown error'}`) - }, + const allClients = allClientsData?.clients || [] + const totalClients = allClientsData?.total || 0 + const filesystemClients = allClients.filter(c => c.category === 'File' || !c.category).length + const databaseClients = allClients.filter(c => c.category === 'Database').length + const virtualizationClients = allClients.filter(c => c.category === 'Virtual').length + + // Generate mock growth data for the last 6 months + const generateGrowthData = () => { + const months = [] + const now = new Date() + for (let i = 5; i >= 0; i--) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1) + const monthName = date.toLocaleDateString('en-US', { month: 'short' }) + + // Simulate growth - start from lower numbers and grow to current counts + const filesystemGrowth = Math.max(0, Math.round(filesystemClients * (0.3 + (5 - i) * 0.14))) + const databaseGrowth = Math.max(0, Math.round(databaseClients * (0.2 + (5 - i) * 0.16))) + const virtualizationGrowth = Math.max(0, Math.round(virtualizationClients * (0.25 + (5 - i) * 0.15))) + + months.push({ + month: monthName, + Filesystem: filesystemGrowth, + Database: databaseGrowth, + Virtualization: virtualizationGrowth, + }) + } + return months + } + + const growthData = generateGrowthData() + + const changeTab = (tab: string) => { + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('tab', tab) + window.history.replaceState({}, '', `${window.location.pathname}?${newSearchParams.toString()}`) + window.location.reload() + } + + return ( +
+ {/* Header */} +
+
+
+

Client Management

+ + {totalClients} Total + +
+

+ Monitor and manage backup clients across filesystem, database, and virtualization platforms. +

+
+
+ + {/* Statistics Cards */} +
+ {/* Filesystem Clients Card */} + + + {/* Database Clients Card */} + + + {/* Virtualization Clients Card */} + +
+ + {/* Growth Chart */} +
+
+
+

Client Growth Trend

+

Client count growth over the last 6 months

+
+
+
+ + + + + + + + + + + + + + + + + + + + [value, 'Clients']} + /> + + + + + + +
+
+
+ ) +} + +// Filesystem Clients Tab Component +function FilesystemClientsTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) { + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [expandedRows, setExpandedRows] = useState>(new Set()) + const [selectedClients, setSelectedClients] = useState>(new Set()) + + const { data, isLoading, error } = useQuery({ + queryKey: ['backup-clients-filesystem', statusFilter, searchQuery], + queryFn: () => backupAPI.listClients({ + category: 'File', + enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled', + search: searchQuery || undefined, + }), }) const clients = data?.clients || [] const total = data?.total || 0 - const formatDate = (dateStr?: string): string => { + const formatTimeAgo = (dateStr?: string): string => { if (!dateStr) return '-' try { const date = new Date(dateStr) @@ -1407,7 +1602,7 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () => const diffMins = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) - + if (diffMins < 1) return 'Just now' if (diffMins < 60) return `${diffMins}m ago` if (diffHours < 24) return `${diffHours}h ago` @@ -1418,594 +1613,254 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () => } } - const getLastBackupStatus = (client: any): { status: 'success' | 'failed' | 'running' | 'warning', text: string, time: string } => { - if (!client.last_backup_at) { - return { status: 'warning', text: 'Never', time: 'No backup yet' } - } - - const date = new Date(client.last_backup_at) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffHours = Math.floor(diffMs / 3600000) - const diffDays = Math.floor(diffMs / 86400000) - - // Mock logic - in real app, this would come from job status - if (diffDays > 7) { - return { status: 'failed', text: 'Failed', time: `${diffDays}d ago (Connection Timed Out)` } - } - if (diffHours > 24) { - return { status: 'warning', text: 'Warning', time: `${diffHours}h ago (Some files skipped)` } - } - return { status: 'success', text: 'Success', time: formatDate(client.last_backup_at) + ' (Daily)' } - } - const toggleRow = (clientId: number) => { - const newExpanded = new Set(expandedRows) - if (newExpanded.has(clientId)) { - newExpanded.delete(clientId) - } else { - newExpanded.add(clientId) - } - setExpandedRows(newExpanded) - } - - const toggleSelectClient = (clientId: number) => { - const newSelected = new Set(selectedClients) - if (newSelected.has(clientId)) { - newSelected.delete(clientId) - } else { - newSelected.add(clientId) - } - setSelectedClients(newSelected) - setSelectAll(newSelected.size === clients.length) - } - - const toggleSelectAll = () => { - if (selectAll) { - setSelectedClients(new Set()) - } else { - setSelectedClients(new Set(clients.map(c => c.client_id))) - } - setSelectAll(!selectAll) - } - - // Extract IP and port from uname (mock - in real app this would come from API) - const getConnectionInfo = (client: any) => { - // Try to extract IP from uname or use mock - const uname = client.uname || '' - const ipMatch = uname.match(/\d+\.\d+\.\d+\.\d+/) - return { - ip: ipMatch ? ipMatch[0] : '192.168.10.25', - port: '9102' - } - } - - // Handler untuk start backup - const handleStartBackup = (client: any) => { - if (!client.name) { - alert('Client name is required') - return - } - setIsStartingBackup(client.client_id) - startBackupMutation.mutate(client.name) - } - - // Handler untuk edit config - redirect ke console dengan command - const handleEditConfig = (client: any) => { - const command = `show client=${client.name}` - if (onSwitchToConsole) { - onSwitchToConsole() - // Set command di console setelah switch menggunakan custom event - setTimeout(() => { - const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement - if (consoleInput) { - // Use Object.defineProperty to set value and trigger React onChange - const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(consoleInput, command) - const event = new Event('input', { bubbles: true }) - consoleInput.dispatchEvent(event) - } else { - consoleInput.value = command - consoleInput.dispatchEvent(new Event('input', { bubbles: true })) - } - consoleInput.focus() - } - }, 200) - } else { - // Fallback: buka console tab - const consoleTab = document.querySelector('[data-tab="console"]') as HTMLElement - if (consoleTab) { - consoleTab.click() - setTimeout(() => { - const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement - if (consoleInput) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(consoleInput, command) - const event = new Event('input', { bubbles: true }) - consoleInput.dispatchEvent(event) - } else { - consoleInput.value = command - consoleInput.dispatchEvent(new Event('input', { bubbles: true })) - } - consoleInput.focus() - } - }, 200) + setExpandedRows(prev => { + const newSet = new Set(prev) + if (newSet.has(clientId)) { + newSet.delete(clientId) + } else { + newSet.add(clientId) } - } + return newSet + }) } - // Handler untuk toggle dropdown menu - const toggleMenu = (clientId: number) => { - setOpenMenuId(openMenuId === clientId ? null : clientId) + const changeTab = (tab: string) => { + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('tab', tab) + window.history.replaceState({}, '', `${window.location.pathname}?${newSearchParams.toString()}`) + window.location.reload() } - // Close menu when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (openMenuId !== null) { - const target = event.target as HTMLElement - if (!target.closest('.dropdown-menu') && !target.closest('.menu-trigger')) { - setOpenMenuId(null) - } - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [openMenuId]) - return ( <> -
+
{/* Header */} -
-
-
-

Client Management

- - {total} Clients - -
-

- Monitor backup status, configure file daemons, and manage client connectivity. -

-
-
- -
+
+ +
{/* Filters */} -
-
-
- search - +
+
+ + search + + setSearchQuery(e.target.value)} - className="w-full bg-surface-highlight border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-1 focus:ring-primary focus:border-primary outline-none placeholder-text-secondary/70 transition-all" />
-
-
- - - - -
-
- - - - -
+
+ + +
-
- - -
+
{/* Clients Table */} -
+
{isLoading ? ( -
Loading clients...
+
Loading clients...
) : error ? (
Failed to load clients
) : clients.length === 0 ? ( -
-

No clients found

-
+
No filesystem clients found
) : ( <>
- - + - - - - - - - - + + + + + + + - - {clients.map((client, idx) => { + + {clients.map((client) => { const isExpanded = expandedRows.has(client.client_id) - const isSelected = selectedClients.has(client.client_id) - const backupStatus = getLastBackupStatus(client) - const connection = getConnectionInfo(client) const isOnline = client.status === 'online' - return ( <> - - + - - - - - - + + + + {isExpanded && ( - - +
- +
+ Client NameCategoryConnectionStatusLast BackupVersionActionsClient NameCategoryConnectionStatusLast BackupVersionActions
+
toggleSelectClient(client.client_id)} + className="rounded border-slate-300 dark:border-slate-600 bg-transparent text-primary focus:ring-primary" + type="checkbox" + checked={selectedClients.has(client.client_id)} + onChange={(e) => { + const newSet = new Set(selectedClients) + if (e.target.checked) { + newSet.add(client.client_id) + } else { + newSet.delete(client.client_id) + } + setSelectedClients(newSet) + }} /> - -
-
- dns -
-
-

{client.name}

-

{client.uname || 'Backup Client'}

+ +
+
+ dns +
+
+
{client.name}
+
13.0.4 (12Feb24) x86_64-pc-linux-gnu
+
- {client.category ? ( - - {client.category === 'File' && folder} - {client.category === 'Database' && storage} - {client.category === 'Virtual' && computer} - {client.category} - - ) : ( - - File - - )} - -
-

{connection.ip}

-

Port: {connection.port}

-
-
- {isOnline ? ( - - - Online - - ) : ( - - link_off - Offline - - )} - -
-
- {backupStatus.status === 'success' && check_circle} - {backupStatus.status === 'failed' && error} - {backupStatus.status === 'running' && } - {backupStatus.status === 'warning' && warning} - {backupStatus.text} -
-

{backupStatus.time}

-
-
- - v22.4.1 + + folder + File -
- - -
- - {openMenuId === client.client_id && ( -
- - - - -
- -
- )} -
+
+
192.168.10.25
+
Port: 9102
+
+ + + {isOnline ? 'Online' : 'Offline'} + + +
+ check_circle + Success
+
{formatTimeAgo(client.last_backup_at)} (Daily)
+
+ v22.4.1 + +
-
-
- Installed Agents & Plugins -
+
+
+
+ extension + Installed Agents & Plugins
-
-
-
-
-
-
-
- folder_open +
+
+
+
+
+ folder_managed
-
-

Standard File Daemon

-

Core Bacula Client

+
+

Standard File Daemon

+

Core Bacula Client Engine

-
-
- Ver - 22.4.1 +
+
+ Ver + 22.4.1
-
+
check_circle - Active + Active
@@ -2021,14 +1876,14 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
-
-

Showing {clients.length} of {total} clients

-
- -
@@ -2037,26 +1892,1247 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
{/* Console Log */} -
-
-
- Console Log (tail -f) - - Connected - +
+
+

+ terminal + Console Log (tail -f) +

+
+ + Connected
-

[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103

-

[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending

-

[14:22:05] bareos-fd: Client "{clients[0]?.name || 'client'}" starting backup of /var/www/html

-

[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping

-

[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.

+
+
+ [14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103 +
+
+ [14:22:02] bareos-sd: Volume "Vol-0012" selected for appending +
+
+ [14:22:05] bareos-fd: Client "{clients[0]?.name || 'client'}" starting backup of /var/www/html +
+
+ [14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping +
+
+ [14:23:45] bareos-dir: JobId 10423: Sending Accurate information. +
+
+ [14:25:12] bareos-dir: Backup completed successfully. +
+
+ _ +
+
) } +// Database/Application Clients Tab Component +function DatabaseClientsTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) { + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [engineFilter, setEngineFilter] = useState('all') + const [expandedRows, setExpandedRows] = useState>(new Set()) + + const { data, isLoading, error } = useQuery({ + queryKey: ['backup-clients-database', statusFilter, searchQuery, engineFilter], + queryFn: () => backupAPI.listClients({ + category: 'Database', + enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled', + search: searchQuery || undefined, + }), + }) + + // Mockup Database clients data + const mockDatabaseClients = [ + { + client_id: 1001, + name: 'postgres-prod-01', + engine: 'PostgreSQL', + engine_version: '15', + ip: '172.24.10.45', + port: '9102', + status: 'online', + last_backup_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago + version: '23.1.2', + os: 'Debian 12 (Bookworm) x64-pc-linux', + backup_type: 'WAL Archiving', + plugin: { + name: 'PostgreSQL Backup Plugin', + version: '23.1.2-b', + description: 'Support for PITR and Incremental Dumps' + } + }, + { + client_id: 1002, + name: 'mysql-webapp-db', + engine: 'MySQL', + engine_version: '8.0', + ip: '192.168.1.100', + port: '9102', + status: 'online', + last_backup_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), // 5 hours ago + version: '22.4.1', + os: 'Ubuntu 22.04 LTS x64-pc-linux', + backup_type: 'Binary Log Replication', + plugin: { + name: 'MySQL Backup Plugin', + version: '22.4.1-m', + description: 'Binary log streaming and point-in-time recovery' + } + }, + { + client_id: 1003, + name: 'oracle-erp-db', + engine: 'Oracle', + engine_version: '19c', + ip: '10.50.20.15', + port: '9102', + status: 'online', + last_backup_at: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), // 1 day ago + version: '22.3.5', + os: 'Oracle Linux 8 x64-pc-linux', + backup_type: 'RMAN Integration', + plugin: { + name: 'Oracle RMAN Plugin', + version: '22.3.5-o', + description: 'RMAN integration for Oracle database backups' + } + }, + { + client_id: 1004, + name: 'sap-hana-prod', + engine: 'SAP HANA', + engine_version: '2.0', + ip: '172.16.5.30', + port: '9102', + status: 'online', + last_backup_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), // 3 hours ago + version: '22.5.0', + os: 'SUSE Linux Enterprise Server 15 x64-pc-linux', + backup_type: 'Backint Integration', + plugin: { + name: 'SAP HANA Backint Plugin', + version: '22.5.0-h', + description: 'SAP HANA Backint interface integration' + } + } + ] + + const clients = data?.clients || [] + const total = data?.total || 0 + + // Add mockup clients to the list if no clients or for demo + const displayClients = clients.length === 0 ? mockDatabaseClients : clients + const displayTotal = clients.length === 0 ? mockDatabaseClients.length : total + + const formatTimeAgo = (dateStr?: string): string => { + if (!dateStr) return '-' + try { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return date.toLocaleDateString() + } catch { + return '-' + } + } + + const toggleRow = (clientId: number) => { + setExpandedRows(prev => { + const newSet = new Set(prev) + if (newSet.has(clientId)) { + newSet.delete(clientId) + } else { + newSet.add(clientId) + } + return newSet + }) + } + + const changeTab = (tab: string) => { + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('tab', tab) + window.history.replaceState({}, '', `${window.location.pathname}?${newSearchParams.toString()}`) + window.location.reload() + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

App & DB Management

+ {displayTotal} Clients +
+

Monitor database engines, application instances, and specialized backup agents.

+
+
+
+ + +
+
+ + {/* Filters */} +
+
+ + search + + setSearchQuery(e.target.value)} + /> +
+
+ + + +
+
+ + + + +
+ +
+ + {/* Clients Table */} +
+ {isLoading ? ( +
Loading clients...
+ ) : error ? ( +
Failed to load clients
+ ) : displayClients.length === 0 ? ( +
No database clients found
+ ) : ( + <> + + + + + + + + + + + + + + + {displayClients.map((client: any) => { + const isExpanded = expandedRows.has(client.client_id) + const isOnline = client.status === 'online' + const engineColor = client.engine === 'PostgreSQL' ? 'bg-blue-500' : + client.engine === 'MySQL' ? 'bg-orange-500' : + client.engine === 'Oracle' ? 'bg-red-500' : + client.engine === 'SAP HANA' ? 'bg-purple-500' : 'bg-blue-400' + + return ( + <> + + + + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
Client NameEngineConnectionStatusLast BackupVersionActions
+
+ +
+ database +
+
+
{client.name}
+
{client.os || 'Debian 12 (Bookworm) x64-pc-linux'}
+
+
+
+ + + {client.engine} {client.engine_version || client.engineVersion || ''} + + +
{client.ip || '172.24.10.45'}
+
Port: {client.port || '9102'}
+
+
+ + + {isOnline ? 'Online' : 'Offline'} + +
+
+
+ check_circle + Success +
+
{formatTimeAgo(client.last_backup_at)} ({client.backup_type || 'WAL Archiving'})
+
+ v{client.version || '23.1.2'} + + +
+
+
+
+
Installed Agents & Database Plugins
+
+
+
+
+ storage +
+
+
{client.plugin?.name || `${client.engine} Backup Plugin`}
+
{client.plugin?.description || 'Database backup and recovery integration'}
+
+
+
+
+ VER + {client.plugin?.version || client.version || '23.1.2-b'} +
+
+ check_circle + Active +
+
+
+
+
+
+
+
Showing 1 - {displayTotal} of {displayTotal} clients
+
+ + +
+
+ + )} +
+ + {/* Console Log */} +
+
+
+
+
+
+
+
+ Console Log (tail -f) +
+
+ + Connected +
+
+
+
[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103
+
[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending
+
[14:22:05] bareos-fd: Client "{displayClients[0]?.name || 'client'}" starting backup of /var/lib/postgresql/15/main
+
[14:23:10] warning: /var/lib/postgresql/15/main/base/16384/2601 locked by another process, skipping...
+
[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.
+
[14:24:12] bareos-fd: Backup successful. Sent 2.4GB to Storage.
+
Waiting for next event...
+
+
+
+ ) +} + +// Virtualization Clients Tab Component +function VirtualizationClientsTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) { + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [expandedRows, setExpandedRows] = useState>(new Set()) + const [showAddVMClientModal, setShowAddVMClientModal] = useState(false) + const queryClient = useQueryClient() + + const { data, isLoading, error } = useQuery({ + queryKey: ['backup-clients-virtualization', statusFilter, searchQuery], + queryFn: () => backupAPI.listClients({ + category: 'Virtual', + enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled', + search: searchQuery || undefined, + }), + }) + + // Mockup Proxmox cluster data + const mockProxmoxCluster = { + client_id: 999, + name: 'Proxmox Cluster (pve-cluster-01)', + ip: '10.0.40.10', + hypervisor: 'Proxmox VE', + version: '8.1', + status: 'online', + last_backup_at: new Date().toISOString(), + vms: [ + { + id: 101, + name: 'Ubuntu-Server-Prod', + status: 'running', + last_backup: 'Today, 03:00 AM', + protection: 'protected', + node: 'pve-01' + }, + { + id: 102, + name: 'Win2022-DC', + status: 'running', + last_backup: 'Yesterday, 11:45 PM', + protection: 'protected', + node: 'pve-01' + }, + { + id: 105, + name: 'vm-staging-test', + status: 'stopped', + last_backup: 'Never', + protection: 'unprotected', + node: 'pve-01' + } + ], + total_vms: 5, + showing_vms: 3 + } + + const clients = data?.clients || [] + const total = data?.total || 0 + + // Add mockup cluster to the list if no clients or for demo + const displayClients = clients.length === 0 ? [mockProxmoxCluster] : clients + const displayTotal = clients.length === 0 ? 1 : total + + const formatTimeAgo = (dateStr?: string): string => { + if (!dateStr) return '-' + try { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return date.toLocaleDateString() + } catch { + return '-' + } + } + + const toggleRow = (clientId: number) => { + setExpandedRows(prev => { + const newSet = new Set(prev) + if (newSet.has(clientId)) { + newSet.delete(clientId) + } else { + newSet.add(clientId) + } + return newSet + }) + } + + const changeTab = (tab: string) => { + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('tab', tab) + window.history.replaceState({}, '', `${window.location.pathname}?${newSearchParams.toString()}`) + window.location.reload() + } + + return ( + <> +
+ {/* Header */} +
+
+ +
+
+

Virtualization Client Management

+ {displayTotal} Clients +
+

Monitor virtual machine backups, hypervisor integrations, and VM snapshot management.

+
+
+
+ + +
+
+ + {/* Filters */} +
+
+ + search + + setSearchQuery(e.target.value)} + /> +
+
+ + + +
+ +
+ + {/* Clients Table */} +
+ {isLoading ? ( +
Loading clients...
+ ) : error ? ( +
Failed to load clients
+ ) : displayClients.length === 0 ? ( +
No virtualization clients found
+ ) : ( + <> + + + + + + + + + + + + + + + {displayClients.map((client: any) => { + const isExpanded = expandedRows.has(client.client_id) + const isOnline = client.status === 'online' + const isProxmox = client.hypervisor === 'Proxmox VE' || client.name?.includes('Proxmox') + + return ( + <> + + + + + + + + + + + {isExpanded && ( + <> + {isProxmox && client.vms ? ( + // Proxmox VMs Table + + + + ) : ( + // Default plugins view for non-Proxmox clients + + + + )} + + )} + + ) + })} + +
Client NameHypervisorConnectionStatusLast BackupVersionActions
+
+ + {isProxmox ? ( +
+ P +
+ ) : ( +
+ dns +
+ )} +
+
{client.name}
+
+ {client.ip ? `${client.ip} • ${client.hypervisor || 'VM Host'} ${client.version || ''}`.trim() : 'VM Host / Hypervisor'} +
+
+
+
+ + + {client.hypervisor || 'VMware vSphere'} + + +
{client.ip || '10.0.50.112'}
+
Port: {client.port || '9102'}
+
+
+ + + {isOnline ? 'CONNECTED' : 'Offline'} + +
+
+
+ check_circle + Success +
+
{formatTimeAgo(client.last_backup_at)} (Snapshot)
+
+ {client.version ? `v${client.version}` : 'v22.4.1'} + + +
+
+
+
+
Virtual Machines
+
+ + + + + + + + + + + + + {client.vms.map((vm: any) => ( + + + + + + + + + ))} + +
VM IDNameStatusLast BackupProtectionActions
+ {vm.id} + +
+ {vm.name.includes('Win') ? ( + desktop_windows + ) : ( + folder + )} + {vm.name} +
+
+
+ + + {vm.status === 'running' ? 'RUNNING' : 'STOPPED'} + +
+
+ {vm.last_backup} + + {vm.protection === 'protected' ? ( +
+ check_circle + Protected +
+ ) : ( +
+ warning + Unprotected +
+ )} +
+ +
+
+
Showing {client.showing_vms || client.vms.length} of {client.total_vms || client.vms.length} VMs found on node {client.vms[0]?.node || 'pve-01'}
+
+ + +
+
+
+
+
+
+
+
+
Installed Agents & Virtualization Plugins
+
+
+
+
+ cloud +
+
+
VMware vSphere Plugin
+
VM snapshot and backup integration
+
+
+
+
+ VER + 22.4.1-vm +
+
+ check_circle + Active +
+
+
+
+
+
+
+
Showing 1 - {displayTotal} of {displayTotal} clients
+
+ + +
+
+ + )} +
+ + {/* Console Log */} +
+
+
+
+
+
+
+
+ Console Log (tail -f) +
+
+ + Connected +
+
+
+
[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103
+
[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending
+
[14:22:05] bareos-fd: Client "{displayClients[0]?.name || 'Proxmox Cluster'}" starting VM snapshot backup
+
[14:23:10] warning: VM "Ubuntu-Server-Prod" is currently powered on, creating snapshot...
+
[14:23:45] bareos-dir: JobId 10423: VM snapshot created successfully.
+
[14:24:12] bareos-fd: Backup successful. Sent 5.2GB to Storage.
+
[14:25:01] bareos-dir: Found {(displayClients[0] as any)?.total_vms || 5} VMs on node pve-01
+
Waiting for next event...
+
+
+
+ + {/* Add New VM Client Modal */} + {showAddVMClientModal && ( + setShowAddVMClientModal(false)} + onSuccess={() => { + setShowAddVMClientModal(false) + queryClient.invalidateQueries({ queryKey: ['backup-clients-virtualization'] }) + }} + /> + )} + + ) +} + +// Add New VM Client Modal Component +function AddNewVMClientModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + const [formData, setFormData] = useState({ + clientName: '', + ipAddress: '', + port: '443', + hypervisorType: 'vmware', + authMethod: 'creds', + username: '', + password: '', + retentionPolicy: 'standard', + storagePool: 'pool-01', + }) + const [currentStep] = useState(1) + + // Fetch storage pools for dropdown + const { data: poolsData } = useQuery({ + queryKey: ['storage-pools'], + queryFn: () => backupAPI.listStoragePools(), + }) + + const pools = poolsData?.pools || [] + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + // TODO: Implement API call to create VM client + console.log('Form submitted:', formData) + // For now, just close modal and refresh + onSuccess() + } + + const handleTestConnection = () => { + // TODO: Implement test connection + console.log('Testing connection to:', formData.ipAddress) + alert('Connection test feature coming soon') + } + + return ( +
+
+ + {/* Modal Header */} +
+
+

Add New VM Client

+

Configure a new virtualization client for backup operations.

+
+ +
+ + {/* Step Indicator */} +
+
+
+ + 1 + + Client Details +
+
+ + 2 + + Hypervisor +
+
+ + 3 + + Authentication +
+
+ + 4 + + Settings +
+
+
+ + {/* Form Content */} +
+
+ {/* Client Information Section */} +
+

+ info + Client Information +

+
+
+ + setFormData({ ...formData, clientName: e.target.value })} + className="w-full bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-[#2d3748] rounded-md px-3 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-500 text-white" + placeholder="e.g. production-vcenter" + required + /> +
+
+
+ + setFormData({ ...formData, ipAddress: e.target.value })} + className="w-full bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-[#2d3748] rounded-md px-3 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all text-white" + placeholder="192.168.1.50" + required + /> +
+
+ + setFormData({ ...formData, port: e.target.value })} + className="w-full bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-[#2d3748] rounded-md px-3 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all text-white" + placeholder="443" + required + /> +
+
+
+
+ + {/* Hypervisor & Auth Section */} +
+

+ storage + Hypervisor & Auth +

+
+
+ +
+ + expand_more +
+
+
+ +
+ + expand_more +
+
+
+
+
+
+ + setFormData({ ...formData, username: e.target.value })} + className="w-full bg-white dark:bg-[#161d27] border border-slate-200 dark:border-[#2d3748] rounded-md px-3 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all text-white" + placeholder="administrator@vsphere.local" + required + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + className="w-full bg-white dark:bg-[#161d27] border border-slate-200 dark:border-[#2d3748] rounded-md px-3 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all text-white" + placeholder="••••••••••••" + required + /> +
+
+
+
+ + {/* Backup Settings Section */} +
+

+ settings_backup_restore + Backup Settings +

+
+
+ +
+ + expand_more +
+
+
+ +
+ + expand_more +
+
+
+
+
+
+ + {/* Modal Footer */} +
+ +
+ + +
+
+
+
+ ) +} + function StorageManagementTab() { const location = useLocation() const searchParams = new URLSearchParams(location.search) diff --git a/frontend/src/pages/ExecutiveDashboard.tsx b/frontend/src/pages/ExecutiveDashboard.tsx new file mode 100644 index 0000000..93894d5 --- /dev/null +++ b/frontend/src/pages/ExecutiveDashboard.tsx @@ -0,0 +1,863 @@ +import { useState, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { backupAPI } from '@/api/backup' +import { zfsApi, storageApi } from '@/api/storage' +import { objectStorageApi } from '@/api/objectStorage' +import { FileDown, FileText } from 'lucide-react' +import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' +import jsPDF from 'jspdf' +import html2canvas from 'html2canvas' +import { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType, AlignmentType } from 'docx' + +const COLORS = ['#1d72f2', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'] + +export default function ExecutiveDashboard() { + const reportRef = useRef(null) + const [isExporting, setIsExporting] = useState(false) + + // Fetch data for dashboard + const { data: clientsData } = useQuery({ + queryKey: ['backup-clients-all'], + queryFn: () => backupAPI.listClients(), + }) + + const { data: jobsData } = useQuery({ + queryKey: ['backup-jobs'], + queryFn: () => backupAPI.listJobs({ limit: 100 }), + }) + + const { data: poolsData } = useQuery({ + queryKey: ['storage-pools'], + queryFn: () => backupAPI.listStoragePools(), + }) + + // Fetch Storage (ZFS) data + const { data: zfsPools = [] } = useQuery({ + queryKey: ['zfs-pools'], + queryFn: () => zfsApi.listPools(), + }) + + const { data: repositories = [] } = useQuery({ + queryKey: ['repositories'], + queryFn: () => storageApi.listRepositories(), + }) + + const { data: disks = [] } = useQuery({ + queryKey: ['disks'], + queryFn: () => storageApi.listDisks(), + }) + + // Fetch Object Storage data + const { data: buckets = [] } = useQuery({ + queryKey: ['object-storage-buckets'], + queryFn: () => objectStorageApi.listBuckets(), + }) + + const { data: objectStorageUsers = [] } = useQuery({ + queryKey: ['object-storage-users'], + queryFn: () => objectStorageApi.listUsers(), + }) + + const clients = clientsData?.clients || [] + const jobs = jobsData?.jobs || [] + const pools = poolsData?.pools || [] + + // Calculate statistics + const totalClients = clients.length + const filesystemClients = clients.filter(c => c.category === 'File' || !c.category).length + const databaseClients = clients.filter(c => c.category === 'Database').length + const virtualizationClients = clients.filter(c => c.category === 'Virtual').length + + const totalJobs = jobs.length + const successfulJobs = jobs.filter(j => j.status === 'Completed').length + const failedJobs = jobs.filter(j => j.status === 'Failed').length + const runningJobs = jobs.filter(j => j.status === 'Running').length + + // Backup Storage metrics + const totalBackupStorage = pools.reduce((sum, p) => sum + (p.total_bytes || 0), 0) + const usedBackupStorage = pools.reduce((sum, p) => sum + (p.used_bytes || 0), 0) + const backupStorageUtilization = totalBackupStorage > 0 ? (usedBackupStorage / totalBackupStorage) * 100 : 0 + + // ZFS Storage metrics + const totalZFSStorage = zfsPools.reduce((sum, p) => sum + (p.size_bytes || 0), 0) + const usedZFSStorage = zfsPools.reduce((sum, p) => sum + (p.used_bytes || 0), 0) + const zfsStorageUtilization = totalZFSStorage > 0 ? (usedZFSStorage / totalZFSStorage) * 100 : 0 + + // Object Storage metrics + const totalObjectStorage = buckets.reduce((sum, b) => sum + (b.size || 0), 0) + const totalObjects = buckets.reduce((sum, b) => sum + (b.objects || 0), 0) + const publicBuckets = buckets.filter(b => b.access_policy !== 'private').length + const privateBuckets = buckets.filter(b => b.access_policy === 'private').length + + // Prepare chart data + const clientDistributionData = [ + { name: 'Filesystem', value: filesystemClients, color: COLORS[0] }, + { name: 'Database', value: databaseClients, color: COLORS[1] }, + { name: 'Virtualization', value: virtualizationClients, color: COLORS[2] }, + ] + + const jobStatusData = [ + { name: 'Successful', value: successfulJobs, color: COLORS[1] }, + { name: 'Failed', value: failedJobs, color: COLORS[3] }, + { name: 'Running', value: runningJobs, color: COLORS[0] }, + { name: 'Other', value: totalJobs - successfulJobs - failedJobs - runningJobs, color: COLORS[4] }, + ] + + const storageDistributionData = [ + { name: 'Backup Storage', value: totalBackupStorage, used: usedBackupStorage, color: COLORS[0] }, + { name: 'ZFS Storage', value: totalZFSStorage, used: usedZFSStorage, color: COLORS[1] }, + { name: 'Object Storage', value: totalObjectStorage, used: totalObjectStorage, color: COLORS[2] }, + ] + + const bucketAccessData = [ + { name: 'Private', value: privateBuckets, color: COLORS[3] }, + { name: 'Public', value: publicBuckets, color: COLORS[1] }, + ] + + // Generate monthly backup trend (last 6 months) + const monthlyTrendData = [] + const now = new Date() + for (let i = 5; i >= 0; i--) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1) + const monthName = date.toLocaleDateString('en-US', { month: 'short' }) + // Mock data - in real app, this would come from API + monthlyTrendData.push({ + month: monthName, + backups: Math.floor(Math.random() * 50) + 20, + size: Math.floor(Math.random() * 500) + 200, + }) + } + + // Export to PDF + const exportToPDF = async () => { + if (!reportRef.current) return + + setIsExporting(true) + try { + const canvas = await html2canvas(reportRef.current, { + scale: 2, + useCORS: true, + logging: false, + }) + + const imgData = canvas.toDataURL('image/png') + const pdf = new jsPDF('p', 'mm', 'a4') + const pdfWidth = pdf.internal.pageSize.getWidth() + const pdfHeight = pdf.internal.pageSize.getHeight() + const imgWidth = canvas.width + const imgHeight = canvas.height + const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight) + const imgScaledWidth = imgWidth * ratio + const imgScaledHeight = imgHeight * ratio + const xOffset = (pdfWidth - imgScaledWidth) / 2 + const yOffset = (pdfHeight - imgScaledHeight) / 2 + + pdf.addImage(imgData, 'PNG', xOffset, yOffset, imgScaledWidth, imgScaledHeight) + + // Add page breaks if content is too long + const totalPages = Math.ceil(imgScaledHeight / pdfHeight) + if (totalPages > 1) { + for (let i = 1; i < totalPages; i++) { + pdf.addPage() + const yPos = -((i * pdfHeight) - yOffset) + pdf.addImage(imgData, 'PNG', xOffset, yPos, imgScaledWidth, imgScaledHeight) + } + } + + pdf.save(`calypso-executive-report-${new Date().toISOString().split('T')[0]}.pdf`) + } catch (error) { + console.error('Error exporting to PDF:', error) + alert('Failed to export PDF. Please try again.') + } finally { + setIsExporting(false) + } + } + + // Export to Word + const exportToWord = async () => { + setIsExporting(true) + try { + const doc = new Document({ + sections: [ + { + properties: {}, + children: [ + new Paragraph({ + text: 'Calypso Executive Dashboard Report', + heading: HeadingLevel.TITLE, + alignment: AlignmentType.CENTER, + }), + new Paragraph({ + text: `Generated on ${new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, + alignment: AlignmentType.CENTER, + }), + new Paragraph({ text: '' }), + + // Executive Summary + new Paragraph({ + text: 'Executive Summary', + heading: HeadingLevel.HEADING_1, + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Clients: `, + bold: true, + }), + new TextRun({ + text: `${totalClients}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Backup Jobs: `, + bold: true, + }), + new TextRun({ + text: `${totalJobs}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Success Rate: `, + bold: true, + }), + new TextRun({ + text: `${totalJobs > 0 ? ((successfulJobs / totalJobs) * 100).toFixed(2) : 0}%`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Backup Storage Utilization: `, + bold: true, + }), + new TextRun({ + text: `${backupStorageUtilization.toFixed(2)}%`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `ZFS Storage Utilization: `, + bold: true, + }), + new TextRun({ + text: `${zfsStorageUtilization.toFixed(2)}%`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Object Storage Buckets: `, + bold: true, + }), + new TextRun({ + text: `${buckets.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Objects: `, + bold: true, + }), + new TextRun({ + text: `${totalObjects}`, + }), + ], + }), + new Paragraph({ text: '' }), + + // Client Distribution + new Paragraph({ + text: 'Client Distribution', + heading: HeadingLevel.HEADING_1, + }), + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph('Category')] }), + new TableCell({ children: [new Paragraph('Count')] }), + ], + }), + ...clientDistributionData.map(item => + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph(item.name)] }), + new TableCell({ children: [new Paragraph(item.value.toString())] }), + ], + }) + ), + ], + }), + new Paragraph({ text: '' }), + + // Job Status + new Paragraph({ + text: 'Backup Job Status', + heading: HeadingLevel.HEADING_1, + }), + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph('Status')] }), + new TableCell({ children: [new Paragraph('Count')] }), + ], + }), + ...jobStatusData.map(item => + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph(item.name)] }), + new TableCell({ children: [new Paragraph(item.value.toString())] }), + ], + }) + ), + ], + }), + new Paragraph({ text: '' }), + + // Backup Storage Information + new Paragraph({ + text: 'Backup Storage Information', + heading: HeadingLevel.HEADING_1, + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Storage Pools: `, + bold: true, + }), + new TextRun({ + text: `${pools.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Capacity: `, + bold: true, + }), + new TextRun({ + text: `${(totalBackupStorage / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Used Storage: `, + bold: true, + }), + new TextRun({ + text: `${(usedBackupStorage / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Available Storage: `, + bold: true, + }), + new TextRun({ + text: `${((totalBackupStorage - usedBackupStorage) / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ text: '' }), + + // ZFS Storage Information + new Paragraph({ + text: 'ZFS Storage Information', + heading: HeadingLevel.HEADING_1, + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total ZFS Pools: `, + bold: true, + }), + new TextRun({ + text: `${zfsPools.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Repositories: `, + bold: true, + }), + new TextRun({ + text: `${repositories.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Physical Disks: `, + bold: true, + }), + new TextRun({ + text: `${disks.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Capacity: `, + bold: true, + }), + new TextRun({ + text: `${(totalZFSStorage / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Used Storage: `, + bold: true, + }), + new TextRun({ + text: `${(usedZFSStorage / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ text: '' }), + + // Object Storage Information + new Paragraph({ + text: 'Object Storage Information', + heading: HeadingLevel.HEADING_1, + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Buckets: `, + bold: true, + }), + new TextRun({ + text: `${buckets.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Objects: `, + bold: true, + }), + new TextRun({ + text: `${totalObjects}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Storage: `, + bold: true, + }), + new TextRun({ + text: `${(totalObjectStorage / (1024 ** 3)).toFixed(2)} GB`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Total Users: `, + bold: true, + }), + new TextRun({ + text: `${objectStorageUsers.length}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Private Buckets: `, + bold: true, + }), + new TextRun({ + text: `${privateBuckets}`, + }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ + text: `Public Buckets: `, + bold: true, + }), + new TextRun({ + text: `${publicBuckets}`, + }), + ], + }), + ], + }, + ], + }) + + const blob = await Packer.toBlob(doc) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `calypso-executive-report-${new Date().toISOString().split('T')[0]}.docx` + link.click() + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('Error exporting to Word:', error) + alert('Failed to export Word document. Please try again.') + } finally { + setIsExporting(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+

Executive Dashboard

+

Comprehensive overview of backup operations and system health

+
+
+ + +
+
+ + {/* Report Content */} +
+ {/* Report Header */} +
+

Calypso Executive Dashboard Report

+

+ Generated on {new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +

+
+ + {/* Key Metrics */} +
+
+
Total Clients
+
{totalClients}
+
+ {filesystemClients} Filesystem • {databaseClients} Database • {virtualizationClients} Virtualization +
+
+
+
Total Jobs
+
{totalJobs}
+
+ {successfulJobs} Successful • {failedJobs} Failed • {runningJobs} Running +
+
+
+
Success Rate
+
+ {totalJobs > 0 ? ((successfulJobs / totalJobs) * 100).toFixed(1) : 0}% +
+
Based on {totalJobs} total jobs
+
+
+
Backup Storage Utilization
+
{backupStorageUtilization.toFixed(1)}%
+
+ {(usedBackupStorage / (1024 ** 3)).toFixed(2)} GB / {(totalBackupStorage / (1024 ** 3)).toFixed(2)} GB +
+
+
+
ZFS Storage Utilization
+
{zfsStorageUtilization.toFixed(1)}%
+
+ {(usedZFSStorage / (1024 ** 3)).toFixed(2)} GB / {(totalZFSStorage / (1024 ** 3)).toFixed(2)} GB +
+
+
+
Object Storage Buckets
+
{buckets.length}
+
+ {totalObjects} Objects • {(totalObjectStorage / (1024 ** 3)).toFixed(2)} GB +
+
+
+ + {/* Charts Section */} +
+ {/* Client Distribution */} +
+

Client Distribution

+ + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {clientDistributionData.map((entry, index) => ( + + ))} + + + + +
+ + {/* Job Status */} +
+

Job Status Distribution

+ + + + + + + + {jobStatusData.map((entry, index) => ( + + ))} + + + +
+ + {/* Storage Distribution */} +
+

Storage Distribution

+ + + + + + `${(value / (1024 ** 3)).toFixed(2)} GB`} /> + + + + + +
+ + {/* Object Storage Bucket Access */} +
+

Object Storage Bucket Access

+ + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {bucketAccessData.map((entry, index) => ( + + ))} + + + + +
+
+ + {/* Monthly Trend */} +
+

Monthly Backup Trend

+ + + + + + + + + + + +
+ + {/* Backup Storage Pools Table */} +
+

Backup Storage Pools

+
+ + + + + + + + + + + + {pools.map((pool) => { + const totalGB = (pool.total_bytes || 0) / (1024 ** 3) + const usedGB = (pool.used_bytes || 0) / (1024 ** 3) + const availableGB = totalGB - usedGB + const utilization = totalGB > 0 ? (usedGB / totalGB) * 100 : 0 + return ( + + + + + + + + ) + })} + +
Pool NameTotal SizeUsedAvailableUtilization
{pool.name}{totalGB.toFixed(2)} GB{usedGB.toFixed(2)} GB{availableGB.toFixed(2)} GB + 80 ? 'text-red-600' : utilization > 60 ? 'text-amber-600' : 'text-emerald-600'}`}> + {utilization.toFixed(1)}% + +
+
+
+ + {/* ZFS Storage Pools Table */} + {zfsPools.length > 0 && ( +
+

ZFS Storage Pools

+
+ + + + + + + + + + + + {zfsPools.map((pool) => { + const totalGB = (pool.size_bytes || 0) / (1024 ** 3) + const usedGB = (pool.used_bytes || 0) / (1024 ** 3) + const availableGB = totalGB - usedGB + const utilization = totalGB > 0 ? (usedGB / totalGB) * 100 : 0 + return ( + + + + + + + + ) + })} + +
Pool NameTotal SizeUsedAvailableUtilization
{pool.name}{totalGB.toFixed(2)} GB{usedGB.toFixed(2)} GB{availableGB.toFixed(2)} GB + 80 ? 'text-red-600' : utilization > 60 ? 'text-amber-600' : 'text-emerald-600'}`}> + {utilization.toFixed(1)}% + +
+
+
+ )} + + {/* Object Storage Buckets Table */} + {buckets.length > 0 && ( +
+

Object Storage Buckets

+
+ + + + + + + + + + + + {buckets.map((bucket) => { + const sizeGB = (bucket.size || 0) / (1024 ** 3) + return ( + + + + + + + + ) + })} + +
Bucket NameSizeObjectsAccess PolicyCreated
{bucket.name}{sizeGB.toFixed(2)} GB{bucket.objects || 0} + + {bucket.access_policy} + + + {new Date(bucket.creation_date).toLocaleDateString()} +
+
+
+ )} +
+
+
+ ) +}