From d1870c1c86211b37a6c7f8307210b9032037ca04 Mon Sep 17 00:00:00 2001 From: Ayumu-Nono Date: Mon, 22 Dec 2025 17:21:57 +0900 Subject: [PATCH 1/4] storage --- app/package-lock.json | 517 +++++++++++++++++- app/package.json | 2 + app/src/App.tsx | 3 + app/src/api/api.ts | 289 ++++++++++ app/src/components/BatchDownloadButton.tsx | 248 +++++++++ app/src/components/DataTable.tsx | 18 +- app/src/components/TableRow.tsx | 27 +- .../components/common/StorageAddressLink.tsx | 270 +++++++++ .../components/common/StorageModeBadge.tsx | 139 +++++ app/src/components/dag/NodeDetails.tsx | 6 +- app/src/components/dag/ProcessDetails.tsx | 14 +- .../operation/OperationTableRow.tsx | 3 +- app/src/components/storage/FileBrowser.tsx | 345 ++++++++++++ app/src/components/storage/FileBrowserV2.tsx | 440 +++++++++++++++ app/src/components/storage/FilePreview.tsx | 237 ++++++++ app/src/components/storage/index.ts | 2 + app/src/contexts/StorageContext.tsx | 53 ++ app/src/pages/ProcessViewPage.tsx | 64 +++ app/src/pages/RunDetailPage.tsx | 85 ++- app/src/pages/RunListPage.tsx | 12 + app/src/services/runStorageService.ts | 138 +++++ app/src/types/api.ts | 1 + app/src/types/data.ts | 1 + app/src/types/storage.ts | 109 ++++ app/src/utils/storageAddress.ts | 85 +++ 25 files changed, 3039 insertions(+), 69 deletions(-) create mode 100644 app/src/components/BatchDownloadButton.tsx create mode 100644 app/src/components/common/StorageAddressLink.tsx create mode 100644 app/src/components/common/StorageModeBadge.tsx create mode 100644 app/src/components/storage/FileBrowser.tsx create mode 100644 app/src/components/storage/FileBrowserV2.tsx create mode 100644 app/src/components/storage/FilePreview.tsx create mode 100644 app/src/components/storage/index.ts create mode 100644 app/src/contexts/StorageContext.tsx create mode 100644 app/src/services/runStorageService.ts create mode 100644 app/src/types/storage.ts create mode 100644 app/src/utils/storageAddress.ts diff --git a/app/package-lock.json b/app/package-lock.json index a643e31..8f53035 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@react-oauth/google": "^0.12.1", "@types/dagre": "^0.7.52", + "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.7.9", "dagre": "^0.8.5", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", + "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.10.4" }, "devDependencies": { @@ -296,6 +298,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", @@ -1520,6 +1531,15 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz", "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "20.17.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz", @@ -1530,17 +1550,21 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "node_modules/@types/react": { "version": "18.3.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1555,6 +1579,21 @@ "@types/react": "*" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -2324,6 +2363,36 @@ "node": ">=4" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2392,6 +2461,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2442,8 +2521,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/d3-color": { "version": "3.1.0", @@ -2567,6 +2645,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3014,6 +3105,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3124,6 +3228,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3301,6 +3413,51 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3352,6 +3509,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3379,6 +3560,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3409,6 +3600,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3590,6 +3791,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3828,6 +4043,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4092,6 +4332,25 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4189,6 +4448,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", @@ -4227,6 +4506,22 @@ "node": ">=8.10.0" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4426,6 +4721,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -5234,6 +5539,11 @@ "@babel/helper-plugin-utils": "^7.25.7" } }, + "@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" + }, "@babel/template": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", @@ -6061,6 +6371,14 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz", "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==" }, + "@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "requires": { + "@types/unist": "*" + } + }, "@types/node": { "version": "20.17.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz", @@ -6070,17 +6388,20 @@ "undici-types": "~6.19.2" } }, + "@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==" + }, "@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "@types/react": { "version": "18.3.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", - "devOptional": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -6095,6 +6416,19 @@ "@types/react": "*" } }, + "@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "requires": { + "@types/react": "*" + } + }, + "@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -6559,6 +6893,21 @@ "supports-color": "^5.3.0" } }, + "character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==" + }, + "character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" + }, + "character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==" + }, "chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6614,6 +6963,11 @@ "delayed-stream": "~1.0.0" } }, + "comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" + }, "commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -6652,8 +7006,7 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "d3-color": { "version": "3.1.0", @@ -6739,6 +7092,14 @@ "ms": "^2.1.3" } }, + "decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "requires": { + "character-entities": "^2.0.0" + } + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7081,6 +7442,14 @@ "reusify": "^1.0.4" } }, + "fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "requires": { + "format": "^0.2.0" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7151,6 +7520,11 @@ "mime-types": "^2.1.12" } }, + "format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==" + }, "fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7277,6 +7651,36 @@ "function-bind": "^1.1.2" } }, + "hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "requires": { + "@types/hast": "^3.0.0" + } + }, + "hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "requires": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + } + }, + "highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" + }, + "highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==" + }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7315,6 +7719,20 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==" + }, + "is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "requires": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + } + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7333,6 +7751,11 @@ "hasown": "^2.0.2" } }, + "is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==" + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7354,6 +7777,11 @@ "is-extglob": "^2.1.1" } }, + "is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7491,6 +7919,15 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "requires": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + } + }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7665,6 +8102,27 @@ "callsites": "^3.0.0" } }, + "parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "requires": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "dependencies": { + "@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + } + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7817,6 +8275,16 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, + "prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" + }, + "property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" + }, "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7874,6 +8342,19 @@ "react-router": "6.28.0" } }, + "react-syntax-highlighter": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "requires": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + } + }, "reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", @@ -7905,6 +8386,17 @@ "picomatch": "^2.2.1" } }, + "refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "requires": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + } + }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -8035,6 +8527,11 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, + "space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" + }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/app/package.json b/app/package.json index ea6376e..96dc5ae 100644 --- a/app/package.json +++ b/app/package.json @@ -12,12 +12,14 @@ "dependencies": { "@react-oauth/google": "^0.12.1", "@types/dagre": "^0.7.52", + "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.7.9", "dagre": "^0.8.5", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", + "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.10.4" }, "devDependencies": { diff --git a/app/src/App.tsx b/app/src/App.tsx index ee1c498..4b0f70f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -2,6 +2,7 @@ import { GoogleOAuthProvider } from '@react-oauth/google'; import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; +import { StorageProvider } from './contexts/StorageContext'; import { LoginPage } from './pages/LoginPage'; import { RunListPage } from './pages/RunListPage'; import { RunDetailPage } from './pages/RunDetailPage'; @@ -27,6 +28,7 @@ function App() { + } /> {/* Main routes - RESTful design */} @@ -44,6 +46,7 @@ function App() { } /> } /> + diff --git a/app/src/api/api.ts b/app/src/api/api.ts index c05c2f7..02d9a59 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -9,6 +9,13 @@ import { EdgeResponse } from '../types/dag'; import { Dag } from '../types/dag'; import { ProcessNode, ProcessDag, ProcessEdge } from '../types/process'; import { OperationDataItem, OperationWithRunResponse } from '../types/operation'; +import { + StorageListResponse, + StoragePreviewResponse, + StorageDownloadResponse, + SortBy, + SortOrder +} from '../types/storage'; // const API_BASE_URL = 'http://0.0.0.0:8000'; const API_BASE_URL = '/log_server_api'; @@ -341,4 +348,286 @@ export const fetchAllOperations = async (user_email: string): Promise => { + try { + const response = await axios.get( + `${API_BASE_URL}/storage/info` + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to fetch storage info', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +// ==================== Storage APIs ==================== + +/** + * S3ストレージ内のファイル・フォルダ一覧を取得 + * + * @param prefix - S3プレフィックス(例: runs/1/) + * @param sortBy - ソート対象(name, size, last_modified) + * @param order - ソート順(asc, desc) + * @param page - ページ番号 + * @param perPage - 1ページあたりの件数 + * @returns ファイル一覧レスポンス + */ +export const fetchStorageList = async ( + prefix: string, + sortBy: SortBy = 'name', + order: SortOrder = 'asc', + page: number = 1, + perPage: number = 50 +): Promise => { + try { + const response = await axios.get( + `${API_BASE_URL}/storage/list`, + { + params: { + prefix, + sort_by: sortBy, + order, + page, + per_page: perPage + } + } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to fetch storage list', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +/** + * テキストファイルの内容をプレビュー取得 + * + * @param filePath - S3キー(例: runs/1/output.json) + * @param maxLines - 最大行数(デフォルト: 1000) + * @returns プレビューレスポンス + */ +export const fetchStoragePreview = async ( + filePath: string, + maxLines: number = 1000 +): Promise => { + try { + const response = await axios.get( + `${API_BASE_URL}/storage/preview`, + { + params: { + file_path: filePath, + max_lines: maxLines + } + } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to fetch file preview', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +/** + * ダウンロード用の事前署名URLを取得 + * + * @param filePath - S3キー(例: runs/1/output.json) + * @param expiresIn - 有効期限(秒)、デフォルト3600秒(1時間) + * @returns ダウンロードURLレスポンス + */ +export const fetchStorageDownloadUrl = async ( + filePath: string, + expiresIn: number = 3600 +): Promise => { + try { + const response = await axios.get( + `${API_BASE_URL}/storage/download`, + { + params: { + file_path: filePath, + expires_in: expiresIn + } + } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to get download URL', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +// ==================== Batch Download APIs ==================== + +/** + * バッチダウンロードの推定サイズレスポンス型 + */ +export interface BatchDownloadEstimate { + run_count: number; + estimated_size: number; + estimated_size_mb: number; + can_download: boolean; + message?: string; +} + +/** + * バッチダウンロードの推定サイズを取得 + * + * @param runIds - ダウンロード対象のランIDリスト + * @returns 推定サイズ情報 + */ +export const estimateBatchDownload = async ( + runIds: string[] +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/storage/batch-download/estimate`, + { run_ids: runIds.map(id => parseInt(id, 10)) } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to estimate batch download size', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +/** + * 複数ランのファイルをZIP形式で一括ダウンロード + * + * @param runIds - ダウンロード対象のランIDリスト + */ +export const downloadRunsAsZip = async ( + runIds: string[] +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/storage/batch-download`, + { run_ids: runIds.map(id => parseInt(id, 10)) }, + { responseType: 'blob' } + ); + + // Content-Dispositionからファイル名を取得 + const contentDisposition = response.headers['content-disposition']; + let filename = 'labcode_runs.zip'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (match) { + filename = match[1]; + } + } + + // Blobを作成してダウンロード + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + // Blobエラーレスポンスをテキストに変換 + if (axiosError.response.data instanceof Blob) { + const text = await (axiosError.response.data as Blob).text(); + try { + const errorData = JSON.parse(text); + throw new APIError( + errorData.detail || 'Failed to download files', + axiosError.response.status, + errorData + ); + } catch { + throw new APIError( + 'Failed to download files', + axiosError.response.status + ); + } + } + throw new APIError( + 'Failed to download files', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } }; \ No newline at end of file diff --git a/app/src/components/BatchDownloadButton.tsx b/app/src/components/BatchDownloadButton.tsx new file mode 100644 index 0000000..92427b5 --- /dev/null +++ b/app/src/components/BatchDownloadButton.tsx @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { Download, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; +import { downloadRunsAsZip, estimateBatchDownload, APIError, BatchDownloadEstimate } from '../api/api'; + +type DownloadStatus = 'idle' | 'estimating' | 'confirming' | 'downloading' | 'completed' | 'error'; + +interface BatchDownloadButtonProps { + /** 選択されたランIDリスト */ + selectedRunIds: string[]; + /** 処理中フラグ(親コンポーネントの状態) */ + isProcessing?: boolean; + /** ダウンロード開始コールバック */ + onDownloadStart?: () => void; + /** ダウンロード完了コールバック */ + onDownloadComplete?: () => void; + /** ダウンロードエラーコールバック */ + onDownloadError?: (error: Error) => void; +} + +/** + * 複数ランの一括ダウンロードボタンコンポーネント + * + * Phase A (MVP): 基本的なダウンロードボタン + * Phase B: 確認ダイアログ、進捗表示を追加予定 + */ +export const BatchDownloadButton: React.FC = ({ + selectedRunIds, + isProcessing = false, + onDownloadStart, + onDownloadComplete, + onDownloadError, +}) => { + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(null); + const [estimate, setEstimate] = useState(null); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + // ダウンロードが有効かどうか + const canDownload = selectedRunIds.length > 0 && !isProcessing && status === 'idle'; + + // 5件以上の場合は確認ダイアログを表示 + const needsConfirmation = selectedRunIds.length >= 5; + + const handleClick = async () => { + if (!canDownload) return; + + // Phase B: 確認ダイアログ対応 + if (needsConfirmation) { + setStatus('estimating'); + try { + const estimateResult = await estimateBatchDownload(selectedRunIds); + setEstimate(estimateResult); + + if (!estimateResult.can_download) { + setStatus('error'); + setErrorMessage(estimateResult.message || 'ダウンロードサイズが上限を超えています'); + return; + } + + setShowConfirmDialog(true); + setStatus('confirming'); + } catch (err) { + handleError(err); + } + return; + } + + // 直接ダウンロード + await executeDownload(); + }; + + const executeDownload = async () => { + setStatus('downloading'); + setErrorMessage(null); + onDownloadStart?.(); + + try { + await downloadRunsAsZip(selectedRunIds); + setStatus('completed'); + onDownloadComplete?.(); + + // 3秒後にアイドル状態に戻す + setTimeout(() => { + setStatus('idle'); + }, 3000); + } catch (err) { + handleError(err); + } + }; + + const handleError = (err: unknown) => { + setStatus('error'); + let message = 'ダウンロードに失敗しました'; + + if (err instanceof APIError) { + switch (err.status) { + case 400: + message = 'ランが選択されていません'; + break; + case 404: + message = '選択したランが見つかりません'; + break; + case 413: + message = 'ダウンロードサイズが上限を超えています'; + break; + case 503: + message = 'ストレージに接続できません'; + break; + default: + message = err.message || message; + } + } + + setErrorMessage(message); + onDownloadError?.(err instanceof Error ? err : new Error(message)); + }; + + const handleConfirm = () => { + setShowConfirmDialog(false); + executeDownload(); + }; + + const handleCancel = () => { + setShowConfirmDialog(false); + setStatus('idle'); + setEstimate(null); + }; + + const handleRetry = () => { + setStatus('idle'); + setErrorMessage(null); + handleClick(); + }; + + // ボタンの状態に応じたレンダリング + const renderButton = () => { + switch (status) { + case 'estimating': + return ( + + ); + + case 'downloading': + return ( + + ); + + case 'completed': + return ( + + ); + + case 'error': + return ( +
+ + {errorMessage && ( + {errorMessage} + )} +
+ ); + + default: + return ( + + ); + } + }; + + return ( + <> + {renderButton()} + + {/* Phase B: 確認ダイアログ */} + {showConfirmDialog && estimate && ( +
+
+
+

+ ダウンロード確認 +

+
+
+

+ {estimate.run_count}件のランをダウンロードします。 +

+

+ 推定サイズ: 約 {estimate.estimated_size_mb.toFixed(1)}MB +

+

+ ファイルサイズによっては時間がかかる場合があります。 +

+
+
+ + +
+
+
+ )} + + ); +}; diff --git a/app/src/components/DataTable.tsx b/app/src/components/DataTable.tsx index e644e76..57b8a9c 100644 --- a/app/src/components/DataTable.tsx +++ b/app/src/components/DataTable.tsx @@ -21,7 +21,7 @@ interface Filters { started_at?: string | null; finished_at?: string | null; status?: RunStatus; - storage_address?: string | null; + storage_mode?: string | null; } // interface Filters { // id: number; @@ -46,17 +46,7 @@ const columns = [ { key: 'started_at' as const, label: 'Start datetime' }, { key: 'finished_at' as const, label: 'finish datetime' }, { key: 'status' as const, label: 'status' }, - { key: 'storage_address' as const, label: 'storage address' }, - // { key: 'projectId' as const, label: 'プロジェクトID' }, - // { key: 'projectName' as const, label: 'プロジェクト名' }, - // { key: 'id' as const, label: 'ID' }, - // { key: 'protocolName' as const, label: 'プロトコル名' }, - // { key: 'registeredAt' as const, label: '登録日時' }, - // { key: 'startAt' as const, label: '開始日時' }, - // { key: 'endAt' as const, label: '終了日時' }, - // { key: 'status' as const, label: 'ステータス' }, - // { key: 'contentMd5' as const, label: 'Content MD5' }, - // { key: 'protocolUrl' as const, label: 'プロトコルURL' }, + { key: 'storage_mode' as const, label: 'Storage' }, ]; export const DataTable: React.FC = ({ @@ -162,10 +152,6 @@ export const DataTable: React.FC = ({ onFilterChange={(value) => handleFilterChange(column.key, value)} /> ))} - {/* ★ 新規: Actions ヘッダー */} - - Actions - diff --git a/app/src/components/TableRow.tsx b/app/src/components/TableRow.tsx index 4bd527f..801e86d 100644 --- a/app/src/components/TableRow.tsx +++ b/app/src/components/TableRow.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { StatusBadge } from './StatusBadge'; import { formatDateTime } from '../utils/dateFormatter'; import { DataItem } from '../types/data'; -import { ExternalLink } from 'lucide-react'; +import { StorageModeBadge } from './common/StorageModeBadge'; interface TableRowProps { item: DataItem; @@ -14,10 +14,6 @@ interface TableRowProps { export const TableRow: React.FC = ({ item, selected, onSelect }) => { const navigate = useNavigate(); - const handleViewOperations = () => { - navigate(`/operations?run_id=${item.id}`); - }; - const handleRowClick = (e: React.MouseEvent) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement; @@ -82,27 +78,8 @@ export const TableRow: React.FC = ({ item, selected, onSelect }) - {/* - {item.storage_address} - */} - - URL - - - - - + ); diff --git a/app/src/components/common/StorageAddressLink.tsx b/app/src/components/common/StorageAddressLink.tsx new file mode 100644 index 0000000..c9cdf3d --- /dev/null +++ b/app/src/components/common/StorageAddressLink.tsx @@ -0,0 +1,270 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ExternalLink, FolderOpen, HardDrive, Cloud, Copy, Check, HelpCircle, Layers } from 'lucide-react'; +import { isExternalUrl, getFullStorageAddress, getStorageModeLabel, isStorageModeUnknown } from '../../utils/storageAddress'; +import { useStorage } from '../../contexts/StorageContext'; + +interface StorageAddressLinkProps { + /** storage_addressの値 */ + address: string | null | undefined; + /** S3パスクリック時に遷移するRunのID */ + runId?: number | string; + /** フルパスを表示するか(デフォルト: false) */ + showFullPath?: boolean; + /** S3パスクリック時のカスタムハンドラ */ + onS3Click?: (address: string) => void; + /** コピー機能を有効にするか(デフォルト: true) */ + enableCopy?: boolean; + /** モードバッジを表示するか(デフォルト: true) */ + showModeBadge?: boolean; + /** Run固有のストレージモード(指定時はこちらを優先) */ + storageMode?: 's3' | 'local' | 'unknown' | null; + /** ハイブリッドモードかどうか */ + isHybrid?: boolean; + /** S3パス(ハイブリッド時) */ + s3Path?: string; + /** ローカルパス(ハイブリッド時) */ + localPath?: string; +} + +/** + * storage_addressを表示するリンクコンポーネント + * + * - S3パス: クリック時にコピー、モードバッジ付き + * - ローカルパス: クリック時にコピー、モードバッジ付き + * - 外部URL: 新しいタブで開く + * - 空: 「-」表示、クリック不可 + */ +export const StorageAddressLink: React.FC = ({ + address, + runId, + showFullPath = false, + onS3Click, + enableCopy = true, + showModeBadge = true, + storageMode, + isHybrid = false, + s3Path, + localPath +}) => { + const navigate = useNavigate(); + const [copied, setCopied] = useState(false); + const [copiedS3, setCopiedS3] = useState(false); + const [copiedLocal, setCopiedLocal] = useState(false); + const { storageInfo } = useStorage(); + + // Run固有のstorage_modeがあればそれを使用、なければサーバーのモードを使用 + const effectiveMode = storageMode ?? storageInfo?.mode; + + // モードバッジ用の設定 + const modeLabel = getStorageModeLabel(effectiveMode); + const isS3 = effectiveMode === 's3'; + const isLocal = effectiveMode === 'local'; + const isUnknown = isStorageModeUnknown(effectiveMode); + const ModeIcon = isS3 ? Cloud : isLocal ? HardDrive : isUnknown ? HelpCircle : FolderOpen; + const modeBadgeClass = isS3 + ? 'bg-orange-100 text-orange-800' + : isLocal + ? 'bg-green-100 text-green-800' + : isUnknown + ? 'bg-gray-200 text-gray-600' + : 'bg-gray-100 text-gray-800'; + + // ハイブリッドモードの場合は両方のバッジとアドレスを表示 + if (isHybrid && (s3Path || localPath)) { + const handleCopyS3 = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (s3Path) { + try { + await navigator.clipboard.writeText(s3Path); + setCopiedS3(true); + setTimeout(() => setCopiedS3(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + } + }; + + const handleCopyLocal = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (localPath) { + try { + await navigator.clipboard.writeText(localPath); + setCopiedLocal(true); + setTimeout(() => setCopiedLocal(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + } + }; + + return ( +
+ {/* Hybridバッジ */} +
+ + + Hybrid + +
+ + {/* S3アドレス */} + {s3Path && ( +
+ + + S3 + + + {copiedS3 && ( + Copied! + )} +
+ )} + + {/* ローカルアドレス */} + {localPath && ( +
+ + + Local + + + {copiedLocal && ( + Copied! + )} +
+ )} +
+ ); + } + + // 空の場合もモードバッジは表示 + if (!address || address.trim() === '') { + return ( +
+ {showModeBadge && modeLabel && ( + + + {modeLabel} + + )} + - +
+ ); + } + + // 外部URLの場合 + if (isExternalUrl(address)) { + return ( + + {showFullPath ? address : 'URL'} + + + ); + } + + // 完全なアドレスを生成(Run固有のモードがあればoverride) + const effectiveStorageInfo = storageMode + ? { + ...storageInfo, + mode: storageMode as 's3' | 'local', + // ローカルモードの場合、db_pathがなければデフォルト値を設定 + db_path: storageMode === 'local' ? (storageInfo?.db_path || '/data/sql_app.db') : storageInfo?.db_path + } + : storageInfo; + const fullAddress = getFullStorageAddress(address, effectiveStorageInfo); + + // コピー処理 + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + try { + await navigator.clipboard.writeText(fullAddress); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + // 表示用テキスト + const displayText = showFullPath ? fullAddress : address; + + return ( +
+ {/* モードバッジ */} + {showModeBadge && modeLabel && ( + + + {modeLabel} + + )} + + {/* アドレス表示とコピーボタン */} + + + {/* コピー成功メッセージ */} + {copied && ( + + Copied! + + )} +
+ ); +}; diff --git a/app/src/components/common/StorageModeBadge.tsx b/app/src/components/common/StorageModeBadge.tsx new file mode 100644 index 0000000..429bb1a --- /dev/null +++ b/app/src/components/common/StorageModeBadge.tsx @@ -0,0 +1,139 @@ +/** + * StorageModeBadge - ストレージモードを表示するバッジコンポーネント + * + * 新しいストレージタイプを追加する場合: + * 1. STORAGE_CONFIG オブジェクトに新しいエントリを追加 + * 2. バックエンドの StorageMode enum と同期させる + */ + +import React from 'react'; +import { Cloud, HardDrive, Layers, HelpCircle, Server, Database } from 'lucide-react'; +import { LucideIcon } from 'lucide-react'; + +/** ストレージモードの設定 */ +interface StorageModeConfig { + label: string; + icon: LucideIcon; + bgColor: string; + textColor: string; +} + +/** + * ストレージモード設定 + * + * 新しいストレージタイプを追加する場合は、ここにエントリを追加してください。 + * 例: 'azure': { label: 'Azure', icon: Cloud, bgColor: 'bg-blue-100', textColor: 'text-blue-800' } + */ +const STORAGE_CONFIG: Record = { + s3: { + label: 'S3', + icon: Cloud, + bgColor: 'bg-orange-100', + textColor: 'text-orange-800', + }, + local: { + label: 'Local', + icon: HardDrive, + bgColor: 'bg-green-100', + textColor: 'text-green-800', + }, + hybrid: { + label: 'Hybrid', + icon: Layers, + bgColor: 'bg-purple-100', + textColor: 'text-purple-800', + }, + unknown: { + label: 'Unknown', + icon: HelpCircle, + bgColor: 'bg-gray-200', + textColor: 'text-gray-600', + }, + // 新しいストレージタイプの例(コメントアウト) + // azure: { + // label: 'Azure', + // icon: Cloud, + // bgColor: 'bg-blue-100', + // textColor: 'text-blue-800', + // }, + // gcs: { + // label: 'GCS', + // icon: Cloud, + // bgColor: 'bg-red-100', + // textColor: 'text-red-800', + // }, +}; + +interface StorageModeBadgeProps { + /** ストレージモード ('s3', 'local', 'hybrid', 'unknown', または null/undefined) */ + mode: string | null | undefined; + /** サイズ(デフォルト: 'md') */ + size?: 'sm' | 'md' | 'lg'; + /** アイコンを表示するか(デフォルト: true) */ + showIcon?: boolean; +} + +/** + * ストレージモードを表示するバッジコンポーネント + * + * S3、Local、Hybrid、Unknown などのモードをビジュアル的に区別できるバッジとして表示します。 + */ +export const StorageModeBadge: React.FC = ({ + mode, + size = 'md', + showIcon = true, +}) => { + // モードに対応する設定を取得(未知のモードはunknown扱い) + const normalizedMode = mode?.toLowerCase() || 'unknown'; + const config = STORAGE_CONFIG[normalizedMode] || STORAGE_CONFIG.unknown; + const Icon = config.icon; + + // サイズに応じたスタイル + const sizeClasses = { + sm: { + badge: 'px-1.5 py-0.5 text-xs', + icon: 'w-3 h-3', + }, + md: { + badge: 'px-2 py-0.5 text-xs', + icon: 'w-3.5 h-3.5', + }, + lg: { + badge: 'px-2.5 py-1 text-sm', + icon: 'w-4 h-4', + }, + }; + + const classes = sizeClasses[size]; + + return ( + + {showIcon && } + {config.label} + + ); +}; + +/** + * ストレージモードのリストを取得 + * + * フィルターやドロップダウンで使用する場合に便利です。 + */ +export const getStorageModes = (): Array<{ value: string; label: string }> => { + return Object.entries(STORAGE_CONFIG).map(([value, config]) => ({ + value, + label: config.label, + })); +}; + +/** + * ストレージモードが有効かどうかをチェック + */ +export const isValidStorageMode = (mode: string | null | undefined): boolean => { + if (!mode) return false; + return mode.toLowerCase() in STORAGE_CONFIG; +}; + +export default StorageModeBadge; diff --git a/app/src/components/dag/NodeDetails.tsx b/app/src/components/dag/NodeDetails.tsx index ae1b935..663a3d6 100644 --- a/app/src/components/dag/NodeDetails.tsx +++ b/app/src/components/dag/NodeDetails.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { formatDateTime } from '../../utils/dateFormatter'; import { StatusBadge } from '../StatusBadge'; -import { ExternalLink } from 'lucide-react'; +import { StorageAddressLink } from '../common/StorageAddressLink'; interface NodeDetailsProps { node: any; // 型を変更して柔軟に対応 @@ -55,9 +55,9 @@ export const NodeDetails: React.FC = ({ node }) => { { label: "Operation ID", value: node.id }, { label: "Start datetime", value: node.started_at ? formatDateTime(node.started_at) : "Not started" }, { label: "Finish datetime", value: node.finished_at ? formatDateTime(node.finished_at) : "Not finished" }, - { label: "Operation storage address", value: {node.storage_address} }, + { label: "Operation storage address", value: }, { label: "Process ID", value: node.process_name }, - { label: "Process storage address", value: {node.process_storage_address} }, + { label: "Process storage address", value: }, { label: "Log", value: node.log } ].map((item, index) => ( diff --git a/app/src/components/dag/ProcessDetails.tsx b/app/src/components/dag/ProcessDetails.tsx index ae76798..b39d9fc 100644 --- a/app/src/components/dag/ProcessDetails.tsx +++ b/app/src/components/dag/ProcessDetails.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { formatDateTime } from '../../utils/dateFormatter'; import { StatusBadge } from '../StatusBadge'; -import { ExternalLink } from 'lucide-react'; +import { StorageAddressLink } from '../common/StorageAddressLink'; import { Process, Port } from '../../types/process'; interface ProcessDetailsProps { @@ -63,17 +63,7 @@ export const ProcessDetails: React.FC = ({ process, onViewO { label: "Finish datetime", value: process.finished_at ? formatDateTime(process.finished_at) : "Not finished" }, { label: "Storage address", - value: process.storage_address ? ( - - URL - - - ) : "Not available" + value: }, ].map((item, index) => ( diff --git a/app/src/components/operation/OperationTableRow.tsx b/app/src/components/operation/OperationTableRow.tsx index 3be0cf0..261a59a 100644 --- a/app/src/components/operation/OperationTableRow.tsx +++ b/app/src/components/operation/OperationTableRow.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { StatusBadge } from '../StatusBadge'; import { OperationDataItem } from '../../types/operation'; import { formatDateTime } from '../../utils/dateFormatter'; +import { StorageAddressLink } from '../common/StorageAddressLink'; interface OperationTableRowProps { item: OperationDataItem; @@ -45,7 +46,7 @@ export const OperationTableRow: React.FC = ({ item }) => {item.finished_at ? formatDateTime(item.finished_at) : 'Not finished'} - {item.storage_address} + {item.is_transport ? 'Yes' : 'No'} diff --git a/app/src/components/storage/FileBrowser.tsx b/app/src/components/storage/FileBrowser.tsx new file mode 100644 index 0000000..ea0b131 --- /dev/null +++ b/app/src/components/storage/FileBrowser.tsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Folder, + File, + Eye, + Download, + ChevronRight, + Home, + ArrowUp, + ArrowDown, + Loader2, + AlertCircle, + ChevronLeft, + ChevronsLeft, + ChevronsRight +} from 'lucide-react'; +import { fetchStorageList, fetchStorageDownloadUrl } from '../../api/api'; +import { FileItem, DirectoryItem, SortBy, SortOrder } from '../../types/storage'; + +interface FileBrowserProps { + /** 初期パス(S3プレフィックス) */ + initialPath: string; + /** ファイル選択時のコールバック */ + onFileSelect?: (file: FileItem) => void; + /** ダウンロードボタン表示 */ + showDownload?: boolean; + /** プレビューボタン表示 */ + showPreview?: boolean; +} + +/** + * S3ストレージ内のファイル・フォルダを表示するブラウザコンポーネント + */ +export const FileBrowser: React.FC = ({ + initialPath, + onFileSelect, + showDownload = true, + showPreview = true, +}) => { + const [currentPath, setCurrentPath] = useState(initialPath); + const [files, setFiles] = useState([]); + const [directories, setDirectories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + // ファイル一覧を取得 + const fetchFiles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetchStorageList(currentPath, sortBy, sortOrder, page); + setFiles(response.files); + setDirectories(response.directories); + setTotalPages(response.pagination.total_pages); + } catch (err) { + setError(err instanceof Error ? err.message : 'ファイル一覧の取得に失敗しました'); + } finally { + setLoading(false); + } + }, [currentPath, sortBy, sortOrder, page]); + + useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + // ディレクトリクリック + const handleDirectoryClick = (path: string) => { + setCurrentPath(path); + setPage(1); + }; + + // プレビュークリック + const handlePreviewClick = (file: FileItem) => { + onFileSelect?.(file); + }; + + // ダウンロードクリック + const handleDownloadClick = async (file: FileItem) => { + try { + const response = await fetchStorageDownloadUrl(file.path); + window.open(response.download_url, '_blank'); + } catch (err) { + console.error('Download failed:', err); + } + }; + + // ソートクリック + const handleSortClick = (column: SortBy) => { + if (sortBy === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(column); + setSortOrder('asc'); + } + setPage(1); + }; + + // パンくずリストの生成 + const breadcrumbs = currentPath.split('/').filter(Boolean); + + // サイズのフォーマット + const formatSize = (bytes: number | null): string => { + if (bytes === null) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + // 日付のフォーマット + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleDateString('ja-JP', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // ソートアイコン + const SortIcon: React.FC<{ column: SortBy }> = ({ column }) => { + if (sortBy !== column) return null; + return sortOrder === 'asc' ? ( + + ) : ( + + ); + }; + + if (loading) { + return ( +
+ + 読み込み中... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + return ( +
+ {/* パンくずリスト */} +
+ +
+ + {/* ファイル一覧テーブル */} +
+ + + + + + + + + + + + {/* ディレクトリ */} + {directories.map((dir) => ( + handleDirectoryClick(dir.path)} + className="hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + ))} + {/* ファイル */} + {files.map((file) => ( + handlePreviewClick(file)} + className="hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + ))} + {/* 空の場合 */} + {files.length === 0 && directories.length === 0 && ( + + + + )} + +
+ Type + handleSortClick('name')} + > + Name + + handleSortClick('size')} + > + Size + + handleSortClick('last_modified')} + > + Last Modified + + + Actions +
+ + + {dir.name}/ + -- + +
+ + + {file.name} + + {formatSize(file.size)} + + {formatDate(file.last_modified)} + +
+ {showPreview && ( + + )} + {showDownload && ( + + )} +
+
+ このディレクトリにファイルはありません +
+
+ + {/* ページネーション */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+
+ + + + +
+
+ )} +
+ ); +}; diff --git a/app/src/components/storage/FileBrowserV2.tsx b/app/src/components/storage/FileBrowserV2.tsx new file mode 100644 index 0000000..d6c2d9e --- /dev/null +++ b/app/src/components/storage/FileBrowserV2.tsx @@ -0,0 +1,440 @@ +/** + * FileBrowserV2 - HAL対応ファイルブラウザ + * + * Run IDベースでファイル/DBデータを統一的に表示するコンポーネント。 + * S3モードとローカルモードの両方に対応。 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Folder, + FolderOpen, + File, + FileText, + FileCode, + Database, + Download, + ChevronRight, + Home, + X, + Loader2, + AlertCircle, + FolderUp, + ScrollText, + Settings, + BarChart3, + LineChart, + AlertTriangle, +} from 'lucide-react'; +import { ContentItem, StorageInfoV2 } from '../../types/storage'; +import { + listContents, + loadContent, + getDownloadUrl, + getStorageInfoV2, +} from '../../services/runStorageService'; + +interface FileBrowserV2Props { + runId: number; + initialPath?: string; + onFileSelect?: (item: ContentItem) => void; + onPreviewContent?: (content: string, item: ContentItem) => void; +} + +export const FileBrowserV2: React.FC = ({ + runId, + initialPath = '', + onFileSelect, + onPreviewContent, +}) => { + const [items, setItems] = useState([]); + const [currentPath, setCurrentPath] = useState(initialPath); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [previewItem, setPreviewItem] = useState(null); + const [previewContent, setPreviewContent] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [storageInfo, setStorageInfo] = useState(null); + + // ファイル一覧を読み込み + const loadContents = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await listContents(runId, currentPath); + // ディレクトリを先に、ファイルを後に並べる + const sorted = [...result.items].sort((a, b) => { + if (a.type === b.type) { + return a.name.localeCompare(b.name); + } + return a.type === 'directory' ? -1 : 1; + }); + setItems(sorted); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load contents'); + } finally { + setLoading(false); + } + }, [runId, currentPath]); + + useEffect(() => { + loadContents(); + }, [loadContents]); + + // ストレージ情報を取得 + useEffect(() => { + const fetchStorageInfo = async () => { + try { + const info = await getStorageInfoV2(runId); + setStorageInfo(info); + } catch (e) { + console.error('Failed to fetch storage info:', e); + } + }; + fetchStorageInfo(); + }, [runId]); + + // ディレクトリクリック + const handleDirectoryClick = (item: ContentItem) => { + setCurrentPath(item.path); + setPreviewItem(null); + setPreviewContent(null); + }; + + // ファイルクリック + const handleFileClick = async (item: ContentItem) => { + // 外部コールバックを呼び出し(設定されている場合) + if (onFileSelect) { + onFileSelect(item); + } + + // 内部プレビューを表示(常に実行) + setPreviewItem(item); + setPreviewLoading(true); + setPreviewContent(null); + + try { + const content = await loadContent(runId, item.path); + setPreviewContent(content); + if (onPreviewContent) { + onPreviewContent(content, item); + } + } catch (e) { + setPreviewContent(`Error loading content: ${e instanceof Error ? e.message : 'Unknown error'}`); + } finally { + setPreviewLoading(false); + } + }; + + // ダウンロード + const handleDownload = async (item: ContentItem) => { + try { + const url = await getDownloadUrl(runId, item.path); + window.open(url, '_blank'); + } catch (e) { + alert(`Download failed: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + }; + + // 親ディレクトリに移動 + const handleGoUp = () => { + if (!currentPath) return; + const parts = currentPath.replace(/\/$/, '').split('/'); + parts.pop(); + const newPath = parts.length > 0 ? parts.join('/') + '/' : ''; + setCurrentPath(newPath); + setPreviewItem(null); + setPreviewContent(null); + }; + + // パンくずリスト生成 + const breadcrumbs = currentPath + ? currentPath.replace(/\/$/, '').split('/').filter(Boolean) + : []; + + // ファイルサイズをフォーマット + const formatSize = (bytes: number): string => { + if (bytes === 0) return '-'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(1)} ${units[i]}`; + }; + + // ソースに応じたアイコンを取得 + const getSourceIcon = (source: ContentItem['source'], isOpen?: boolean) => { + const iconClass = "w-5 h-5"; + switch (source) { + case 'db': + return ; + case 'file': + return isOpen + ? + : ; + case 'virtual': + return isOpen + ? + : ; + default: + return ; + } + }; + + // コンテンツタイプに応じたアイコンを取得 + const getContentTypeIcon = (contentType: string, source: ContentItem['source']) => { + const iconClass = "w-5 h-5"; + + // DBソースの場合は紫色系 + if (source === 'db') { + switch (contentType) { + case 'operation_log': + return ; + default: + return ; + } + } + + // ファイルソースの場合 + switch (contentType) { + case 'operation_log': + return ; + case 'protocol_yaml': + return ; + case 'manipulate_yaml': + return ; + case 'process_data': + return ; + case 'measurement': + return ; + default: + return ; + } + }; + + // ソースバッジを取得 + const getSourceBadge = (source: ContentItem['source']) => { + switch (source) { + case 'db': + return ( + + DB + + ); + case 'file': + return ( + + File + + ); + case 'virtual': + return ( + + Virtual + + ); + default: + return null; + } + }; + + // バックエンドバッジを取得 + const getBackendBadge = (backend?: ContentItem['backend']) => { + if (!backend) return null; + switch (backend) { + case 's3': + return ( + + S3 + + ); + case 'local': + return ( + + Local + + ); + default: + return null; + } + }; + + return ( +
+ {/* ヘッダー */} +
+ + Storage Browser + + Run #{runId} + +
+ + {/* ストレージ情報警告 */} + {storageInfo?.warning && ( +
+ + {storageInfo.warning} +
+ )} + + {/* パンくずリスト */} +
+ + {breadcrumbs.map((crumb, index) => ( + + + + + ))} +
+ +
+ {/* ファイル一覧 */} +
+ {loading ? ( +
+ + Loading contents... +
+ ) : error ? ( +
+ + {error} +
+ ) : items.length === 0 ? ( +
+ + No files in this directory +
+ ) : ( +
+ {/* 親ディレクトリへ */} + {currentPath && ( +
+ + .. +
+ )} + + {/* ファイル/ディレクトリ一覧 */} + {items.map((item) => ( +
+ item.type === 'directory' + ? handleDirectoryClick(item) + : handleFileClick(item) + } + className={`px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors group ${ + previewItem?.path === item.path + ? 'bg-blue-50 border-l-2 border-blue-500' + : 'hover:bg-gray-50' + }`} + > + {/* アイコン */} +
+ {item.type === 'directory' + ? getSourceIcon(item.source, currentPath.startsWith(item.path)) + : getContentTypeIcon(item.contentType, item.source)} +
+ + {/* ファイル名とメタ情報 */} +
+
+ + {item.name} + + {getSourceBadge(item.source)} + {getBackendBadge(item.backend)} +
+ {item.type === 'file' && ( +
+ {formatSize(item.size)} +
+ )} +
+ + {/* ダウンロードボタン */} + {item.type === 'file' && ( + + )} +
+ ))} +
+ )} +
+ + {/* プレビューパネル */} + {previewItem && ( +
+
+
+ {getContentTypeIcon(previewItem.contentType, previewItem.source)} + {previewItem.name} + {getSourceBadge(previewItem.source)} + {getBackendBadge(previewItem.backend)} +
+ +
+
+ {previewLoading ? ( +
+ + Loading preview... +
+ ) : previewContent ? ( +
+                  {previewContent}
+                
+ ) : ( +
+ + No preview available +
+ )} +
+
+ )} +
+
+ ); +}; + +export default FileBrowserV2; diff --git a/app/src/components/storage/FilePreview.tsx b/app/src/components/storage/FilePreview.tsx new file mode 100644 index 0000000..1f3bc7c --- /dev/null +++ b/app/src/components/storage/FilePreview.tsx @@ -0,0 +1,237 @@ +import React, { useState, useEffect } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { + X, + Copy, + Download, + Loader2, + AlertCircle, + Check, + AlertTriangle +} from 'lucide-react'; +import { fetchStoragePreview, fetchStorageDownloadUrl } from '../../api/api'; +import { StoragePreviewResponse } from '../../types/storage'; + +interface FilePreviewProps { + /** ファイルパス(S3キー) */ + filePath: string; + /** ファイル名(表示用) */ + fileName: string; + /** モーダル開閉状態 */ + open: boolean; + /** 閉じるコールバック */ + onClose: () => void; +} + +/** + * テキストファイルのプレビューをモーダルダイアログで表示するコンポーネント + */ +export const FilePreview: React.FC = ({ + filePath, + fileName, + open, + onClose, +}) => { + const [content, setContent] = useState(''); + const [contentType, setContentType] = useState<'text' | 'json' | 'yaml' | 'binary'>('text'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [truncated, setTruncated] = useState(false); + const [copied, setCopied] = useState(false); + + // プレビューを取得 + useEffect(() => { + if (open && filePath) { + const fetchPreview = async () => { + setLoading(true); + setError(null); + try { + const response: StoragePreviewResponse = await fetchStoragePreview(filePath); + setContent(response.content); + setContentType(response.content_type); + setTruncated(response.truncated); + } catch (err) { + if (err instanceof Error) { + // バイナリファイルの場合 + if (err.message.includes('Binary') || err.message.includes('415')) { + setError('このファイル形式はプレビューできません。ダウンロードしてください。'); + } else { + setError(err.message); + } + } else { + setError('ファイルのプレビューに失敗しました'); + } + } finally { + setLoading(false); + } + }; + fetchPreview(); + } + }, [open, filePath]); + + // コピー + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }; + + // ダウンロード + const handleDownload = async () => { + try { + const response = await fetchStorageDownloadUrl(filePath); + window.open(response.download_url, '_blank'); + } catch (err) { + console.error('Download failed:', err); + } + }; + + // シンタックスハイライトの言語を取得 + const getLanguage = (): string => { + switch (contentType) { + case 'json': + return 'json'; + case 'yaml': + return 'yaml'; + default: + return 'text'; + } + }; + + // モーダル外クリックで閉じる + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + // ESCキーで閉じる + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && open) { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
+ {/* ヘッダー */} +
+

+ {fileName} +

+ +
+ + {/* コンテンツ */} +
+ {loading && ( +
+ + 読み込み中... +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {!loading && !error && ( +
+ + {content} + +
+ )} + + {/* 切り詰め警告 */} + {truncated && ( +
+ + + ファイルが大きいため、1000行までの表示です。完全な内容はダウンロードしてください。 + +
+ )} +
+ + {/* フッター */} +
+
+ {contentType.toUpperCase()} file +
+
+ + + +
+
+
+
+ ); +}; diff --git a/app/src/components/storage/index.ts b/app/src/components/storage/index.ts new file mode 100644 index 0000000..4e54d3f --- /dev/null +++ b/app/src/components/storage/index.ts @@ -0,0 +1,2 @@ +export { FileBrowser } from './FileBrowser'; +export { FilePreview } from './FilePreview'; diff --git a/app/src/contexts/StorageContext.tsx b/app/src/contexts/StorageContext.tsx new file mode 100644 index 0000000..8df19ad --- /dev/null +++ b/app/src/contexts/StorageContext.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { fetchStorageInfo, StorageInfoResponse } from '../api/api'; + +interface StorageContextType { + storageInfo: StorageInfoResponse | null; + isLoading: boolean; + error: string | null; + refresh: () => Promise; +} + +const StorageContext = createContext(null); + +export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [storageInfo, setStorageInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadStorageInfo = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const info = await fetchStorageInfo(); + setStorageInfo(info); + } catch (err) { + console.error('Failed to fetch storage info:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch storage info'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadStorageInfo(); + }, [loadStorageInfo]); + + const refresh = useCallback(async () => { + await loadStorageInfo(); + }, [loadStorageInfo]); + + return ( + + {children} + + ); +}; + +export const useStorage = () => { + const context = useContext(StorageContext); + if (!context) { + throw new Error('useStorage must be used within a StorageProvider'); + } + return context; +}; diff --git a/app/src/pages/ProcessViewPage.tsx b/app/src/pages/ProcessViewPage.tsx index 091db83..82d7933 100644 --- a/app/src/pages/ProcessViewPage.tsx +++ b/app/src/pages/ProcessViewPage.tsx @@ -5,11 +5,15 @@ import { Breadcrumbs } from '../components/Breadcrumbs'; import { useAuth } from '../contexts/AuthContext'; import { StatusBadge } from '../components/StatusBadge'; import { formatDateTime } from '../utils/dateFormatter'; +import { isExternalUrl } from '../utils/storageAddress'; import { ProcessViewer } from '../components/dag/ProcessViewer'; // ★新規コンポーネント import { fetchProcesses, fetchRun, fetchUser } from '../api/api'; // ★fetchProcesses実装済み import { RunResponse } from '../types/api'; import { ProcessNode, ProcessEdge } from '../types/process'; // ★新規型定義 import { Edge } from 'reactflow'; +import { FileBrowser, FilePreview } from '../components/storage'; +import { FileItem } from '../types/storage'; +import { AlertTriangle, ExternalLink } from 'lucide-react'; export const ProcessViewPage: React.FC = () => { const navigate = useNavigate(); @@ -17,6 +21,7 @@ export const ProcessViewPage: React.FC = () => { const { user } = useAuth(); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); + const [previewFile, setPreviewFile] = useState(null); const [run, setRun] = useState({ id: 0, project_id: 0, @@ -137,7 +142,66 @@ export const ProcessViewPage: React.FC = () => { + + {/* Storage Browser Section */} + {run.storage_address && ( +
+
+

+ Storage Browser +

+

+ {run.storage_address} +

+
+ {isExternalUrl(run.storage_address) ? ( + /* 外部URLの場合: 警告表示とリンク */ +
+
+
+ +
+

+ External Storage Link +

+

+ This run uses external storage. File browsing is not available for external URLs. +

+ + + Open External Link + +
+
+
+
+ ) : ( + /* S3パスの場合: FileBrowser表示 */ +
+ setPreviewFile(file)} + /> +
+ )} +
+ )} + + {/* File Preview Modal */} + {previewFile && ( + setPreviewFile(null)} + /> + )} ); }; diff --git a/app/src/pages/RunDetailPage.tsx b/app/src/pages/RunDetailPage.tsx index 2bcb835..c65de6a 100644 --- a/app/src/pages/RunDetailPage.tsx +++ b/app/src/pages/RunDetailPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useParams, Navigate, useNavigate } from 'react-router-dom'; +import { Database, Download, AlertTriangle } from 'lucide-react'; import { UserProfile } from '../components/UserProfile'; import { Breadcrumbs } from '../components/Breadcrumbs'; import { useAuth } from '../contexts/AuthContext'; @@ -7,6 +8,11 @@ import { StatusBadge } from '../components/StatusBadge'; import { formatDateTime } from '../utils/dateFormatter'; import { fetchRun, fetchUser } from '../api/api'; import { RunResponse } from '../types/api'; +import { FileBrowserV2 } from '../components/storage/FileBrowserV2'; +import { StorageAddressLink } from '../components/common/StorageAddressLink'; +import { isStorageModeUnknown } from '../utils/storageAddress'; +import { getStorageInfoV2 } from '../services/runStorageService'; +import { StorageInfoV2 } from '../types/storage'; export const RunDetailPage: React.FC = () => { @@ -23,9 +29,11 @@ export const RunDetailPage: React.FC = () => { started_at: "", finished_at: "", status: "completed", - storage_address: "" + storage_address: "", + storage_mode: null } - ); + ); + const [storageInfo, setStorageInfo] = useState(null); useEffect(() => { const id_num = id ? parseInt(id, 10) : NaN; @@ -38,6 +46,14 @@ export const RunDetailPage: React.FC = () => { navigate('/forbidden', { replace: true }); } setRun(result); + + // ストレージ情報を取得 + try { + const info = await getStorageInfoV2(id_num); + setStorageInfo(info); + } catch (e) { + console.error('Failed to fetch storage info:', e); + } } catch (err) { if (err.status == 404) { navigate('/not_found', { replace: true }); @@ -105,6 +121,20 @@ export const RunDetailPage: React.FC = () => { {run.finished_at ? formatDateTime(run.finished_at): 'Not finished'}

+
+

Storage Address

+
+ +
+
@@ -130,6 +160,57 @@ export const RunDetailPage: React.FC = () => { + + {/* storage_mode未設定の警告バナー */} + {run.id > 0 && isStorageModeUnknown(run.storage_mode) && ( +
+
+
+ +
+
+

+ 警告: このRunのストレージモードが未設定です。 + データの表示が正しくない可能性があります。 +

+
+
+
+ )} + + {/* Storage Browser Section - HAL v2対応 */} + {run.id > 0 && ( +
+
+
+

+ Storage Browser +

+

+ {storageInfo?.isHybrid ? 'Hybrid Mode (S3 + Local)' : + run.storage_mode === 's3' ? 'S3 Mode' : + run.storage_mode === 'local' ? 'Local Mode' : 'Unknown Mode'} +

+
+ {/* ローカルモード時のSQLダンプダウンロードボタン */} + {run.storage_mode === 'local' && ( + + + + SQL Dump + + )} +
+
+ +
+
+ )} ); diff --git a/app/src/pages/RunListPage.tsx b/app/src/pages/RunListPage.tsx index f9a32f7..3ae2050 100644 --- a/app/src/pages/RunListPage.tsx +++ b/app/src/pages/RunListPage.tsx @@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'; import { VisibilityToggle } from '../components/VisibilityToggle'; import { SelectionControls } from '../components/SelectionControls'; import { BulkVisibilityControls } from '../components/BulkVisibilityControls'; +import { BatchDownloadButton } from '../components/BatchDownloadButton'; import { useAuth } from '../contexts/AuthContext'; // import { mockData } from '../data/mockData'; import { fetchRuns, updateRunVisibility } from '../api/api'; @@ -153,6 +154,17 @@ export const RunListPage: React.FC = () => { isProcessing={isProcessing} /> + {/* バッチダウンロードボタン */} +
+ setIsProcessing(true)} + onDownloadComplete={() => setIsProcessing(false)} + onDownloadError={() => setIsProcessing(false)} + /> +
+ {selectedRunIds.length > 0 && ( http://log_server:8000/api に書き換え +const API_BASE = '/log_server_api/v2/storage'; + +/** + * Run内のコンテンツ一覧を取得 + * + * モードに関係なく統一的なインターフェースで取得可能 + */ +export async function listContents( + runId: number, + prefix: string = '' +): Promise { + const response = await fetch( + `${API_BASE}/list/${runId}?prefix=${encodeURIComponent(prefix)}` + ); + if (!response.ok) { + throw new Error(`Failed to list contents: ${response.statusText}`); + } + return response.json(); +} + +/** + * コンテンツを取得(プレビュー用) + */ +export async function loadContent( + runId: number, + path: string +): Promise { + const response = await fetch( + `${API_BASE}/content/${runId}?path=${encodeURIComponent(path)}` + ); + if (!response.ok) { + throw new Error(`Failed to load content: ${response.statusText}`); + } + const data: ContentResponse = await response.json(); + if (data.encoding === 'base64') { + // バイナリデータの場合はBase64デコード + return atob(data.content); + } + return data.content; +} + +/** + * ダウンロードURLを取得 + * + * S3モードの場合は事前署名URLを返す + * ローカルモードの場合はプロキシ経由のURLに変換 + */ +export async function getDownloadUrl( + runId: number, + path: string +): Promise { + const response = await fetch( + `${API_BASE}/download/${runId}?path=${encodeURIComponent(path)}` + ); + if (!response.ok) { + throw new Error(`Failed to get download URL: ${response.statusText}`); + } + const data: DownloadUrlResponse = await response.json(); + + // S3の事前署名URLはそのまま返す(https://で始まる場合) + if (data.url.startsWith('https://') || data.url.startsWith('http://')) { + return data.url; + } + + // ローカルモードのURLはプロキシ経由に変換 + // /api/v2/... -> /log_server_api/v2/... + if (data.url.startsWith('/api/')) { + return data.url.replace('/api/', '/log_server_api/'); + } + + return data.url; +} + +/** + * Runのストレージ情報を取得 + */ +export async function getStorageInfoV2( + runId: number +): Promise { + const response = await fetch(`${API_BASE}/info/${runId}`); + if (!response.ok) { + throw new Error(`Failed to get storage info: ${response.statusText}`); + } + return response.json(); +} + +/** + * データソースに応じたアイコンを取得 + */ +export function getSourceIcon(source: ContentItem['source']): string { + switch (source) { + case 'db': + return '🗄️'; // DBアイコン + case 'file': + return '📁'; // ファイルアイコン + case 'virtual': + return '📂'; // 仮想ディレクトリアイコン + default: + return '📄'; + } +} + +/** + * コンテンツタイプに応じたアイコンを取得 + */ +export function getContentTypeIcon(contentType: string): string { + switch (contentType) { + case 'operation_log': + return '📝'; + case 'protocol_yaml': + return '📋'; + case 'manipulate_yaml': + return '⚙️'; + case 'process_data': + return '📊'; + case 'measurement': + return '📈'; + default: + return '📄'; + } +} diff --git a/app/src/types/api.ts b/app/src/types/api.ts index a5dc124..8c3e523 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -23,4 +23,5 @@ export interface RunResponse { finished_at: string | null; status: RunStatus; storage_address: string; + storage_mode?: 's3' | 'local' | null; // ★追加: Run作成時のストレージモード } \ No newline at end of file diff --git a/app/src/types/data.ts b/app/src/types/data.ts index 44b921c..a2b1826 100644 --- a/app/src/types/data.ts +++ b/app/src/types/data.ts @@ -12,6 +12,7 @@ export interface DataItem { finished_at: string | null; status: RunStatus; storage_address: string; + storage_mode?: 's3' | 'local' | null; // ★追加: Run作成時のストレージモード display_visible: boolean; } diff --git a/app/src/types/storage.ts b/app/src/types/storage.ts new file mode 100644 index 0000000..0b46c93 --- /dev/null +++ b/app/src/types/storage.ts @@ -0,0 +1,109 @@ +/** + * ストレージ関連の型定義 + */ + +/** ファイルアイテム */ +export interface FileItem { + name: string; + type: 'file'; + path: string; + size: number | null; + last_modified: string | null; + extension: string | null; +} + +/** ディレクトリアイテム */ +export interface DirectoryItem { + name: string; + type: 'directory'; + path: string; +} + +/** ページネーション情報 */ +export interface PaginationInfo { + total: number; + page: number; + per_page: number; + total_pages: number; +} + +/** ファイル一覧レスポンス */ +export interface StorageListResponse { + files: FileItem[]; + directories: DirectoryItem[]; + pagination: PaginationInfo; +} + +/** プレビューレスポンス */ +export interface StoragePreviewResponse { + content: string; + content_type: 'text' | 'json' | 'yaml' | 'binary'; + size: number; + last_modified: string; + truncated: boolean; +} + +/** ダウンロードURLレスポンス */ +export interface StorageDownloadResponse { + download_url: string; + expires_at: string; +} + +/** ソート対象 */ +export type SortBy = 'name' | 'size' | 'last_modified'; + +/** ソート順 */ +export type SortOrder = 'asc' | 'desc'; + +// ======================================== +// HAL (Hybrid Access Layer) v2 API用の型定義 +// ======================================== + +/** v2 APIのコンテンツアイテム */ +export interface ContentItem { + name: string; + path: string; + type: 'file' | 'directory'; + size: number; + lastModified: string | null; + contentType: string; + source: 'file' | 'db' | 'virtual'; + backend?: 's3' | 'local'; // どのバックエンドからのデータか +} + +/** v2 APIのコンテンツ一覧レスポンス */ +export interface ListContentsResponse { + run_id: number; + prefix: string; + items: ContentItem[]; +} + +/** v2 APIのコンテンツ取得レスポンス */ +export interface ContentResponse { + content: string; + encoding: 'utf-8' | 'base64'; +} + +/** v2 APIのダウンロードURLレスポンス */ +export interface DownloadUrlResponse { + url: string; + run_id: number; + path: string; +} + +/** v2 APIのストレージ情報 */ +export interface StorageInfoV2 { + mode: 's3' | 'local' | 'unknown'; + storage_address: string; + full_path: string; + data_sources: { + logs: string; + yaml: string; + data: string; + }; + warning?: string; // storage_mode未設定時の警告メッセージ + inferred?: boolean; // モードが推論されたかどうか + isHybrid?: boolean; // ハイブリッドモードかどうか + s3Path?: string; // S3パス(ハイブリッド時) + localPath?: string; // ローカルパス(ハイブリッド時) +} diff --git a/app/src/utils/storageAddress.ts b/app/src/utils/storageAddress.ts new file mode 100644 index 0000000..aa4b58e --- /dev/null +++ b/app/src/utils/storageAddress.ts @@ -0,0 +1,85 @@ +/** + * storage_address判定ユーティリティ + * + * storage_addressの値がS3パスか外部URLかを判定するための関数群 + */ + +import { StorageInfoResponse } from '../api/api'; + +/** + * storage_addressが外部URLかどうかを判定 + * @param address storage_addressの値 + * @returns true: 外部URL, false: S3パスまたは空 + */ +export const isExternalUrl = (address: string | null | undefined): boolean => { + if (!address) return false; + return address.startsWith('http://') || address.startsWith('https://'); +}; + +/** + * storage_addressの種類 + */ +export type StorageAddressType = 'external_url' | 's3_path' | 'local_path' | 'empty'; + +/** + * storage_addressの種類を取得 + * @param address storage_addressの値 + * @param storageMode ストレージモード('s3' or 'local') + * @returns StorageAddressType + */ +export const getStorageAddressType = ( + address: string | null | undefined, + storageMode?: 's3' | 'local' +): StorageAddressType => { + if (!address || address.trim() === '') return 'empty'; + if (isExternalUrl(address)) return 'external_url'; + if (storageMode === 'local') return 'local_path'; + return 's3_path'; +}; + +/** + * 完全なストレージアドレスを生成 + * @param address 相対パス(例: runs/1/) + * @param storageInfo ストレージ情報 + * @returns 完全なアドレス(S3 URI または SQLファイルパス) + */ +export const getFullStorageAddress = ( + address: string | null | undefined, + storageInfo: StorageInfoResponse | null +): string => { + if (!address || address.trim() === '') return ''; + if (isExternalUrl(address)) return address; + + if (!storageInfo) return address; + + if (storageInfo.mode === 's3' && storageInfo.bucket_name) { + // S3 URIを生成: s3://bucket-name/path + return `s3://${storageInfo.bucket_name}/${address}`; + } else if (storageInfo.mode === 'local') { + // ローカルモードではSQLファイルのパスを返す + return storageInfo.db_path || '/data/sql_app.db'; + } + + return address; +}; + +/** + * ストレージモードのラベルを取得 + * @param mode ストレージモード + * @returns 表示用ラベル + */ +export const getStorageModeLabel = (mode: 's3' | 'local' | 'unknown' | null | undefined): string => { + if (mode === 's3') return 'S3'; + if (mode === 'local') return 'Local'; + if (mode === 'unknown' || mode === null) return 'Unknown'; + return ''; +}; + +/** + * ストレージモードがUnknownかどうかを判定 + * @param mode ストレージモード + * @returns true: Unknown (null含む) + */ +export const isStorageModeUnknown = (mode: string | null | undefined): boolean => { + return mode === 'unknown' || mode === null || mode === undefined; +}; From 58b4442f1e60db47c381801501e34f83c643b7f5 Mon Sep 17 00:00:00 2001 From: Ayumu-Nono Date: Tue, 23 Dec 2025 21:45:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?storage=E5=AE=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/src/api/api.ts | 315 ++++++++++++++++++ app/src/components/BatchDownloadButton.tsx | 145 +++++--- .../storage/DownloadFormatSelector.tsx | 132 ++++++++ app/src/pages/OperationListPage.tsx | 2 +- app/src/pages/ProcessViewPage.tsx | 2 +- app/src/pages/RunDetailPage.tsx | 20 +- app/src/pages/RunListPage.tsx | 2 +- 7 files changed, 551 insertions(+), 67 deletions(-) create mode 100644 app/src/components/storage/DownloadFormatSelector.tsx diff --git a/app/src/api/api.ts b/app/src/api/api.ts index 02d9a59..051d191 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -630,4 +630,319 @@ export const downloadRunsAsZip = async ( } throw new APIError(`Request setup error: ${(error as Error).message}`); } +}; + +/** + * 複数ランのファイルをZIP形式で一括ダウンロード(進捗コールバック付き) + * + * @param runIds - ダウンロード対象のランIDリスト + * @param onProgress - 進捗コールバック(0-100%) + */ +export const downloadRunsAsZipWithProgress = async ( + runIds: string[], + onProgress?: (progress: number) => void +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/storage/batch-download`, + { run_ids: runIds.map(id => parseInt(id, 10)) }, + { + responseType: 'blob', + onDownloadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(progress); + } else if (onProgress && progressEvent.loaded) { + // Totalがない場合は不定進捗(ストリーミング) + // 読み込みサイズから推定(最大500MBを想定) + const estimatedTotal = 500 * 1024 * 1024; + const progress = Math.min(Math.round((progressEvent.loaded * 100) / estimatedTotal), 99); + onProgress(progress); + } + } + } + ); + + // Content-Dispositionからファイル名を取得 + const contentDisposition = response.headers['content-disposition']; + let filename = 'labcode_runs.zip'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (match) { + filename = match[1]; + } + } + + // Blobを作成してダウンロード + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + // 完了 + onProgress?.(100); + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + // Blobエラーレスポンスをテキストに変換 + if (axiosError.response.data instanceof Blob) { + const text = await (axiosError.response.data as Blob).text(); + try { + const errorData = JSON.parse(text); + throw new APIError( + errorData.detail || 'Failed to download files', + axiosError.response.status, + errorData + ); + } catch { + throw new APIError( + 'Failed to download files', + axiosError.response.status + ); + } + } + throw new APIError( + 'Failed to download files', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +// ==================== Batch Download V2 APIs (HAL対応) ==================== + +/** + * HAL対応バッチダウンロードの推定サイズレスポンス型 + */ +export interface BatchDownloadV2Estimate { + run_count: number; + total_files: number; + estimated_size_bytes: number; + estimated_size_mb: number; + can_download: boolean; + message?: string; + runs_detail: Array<{ + run_id: number; + storage_mode?: string; + file_count?: number; + estimated_size?: number; + error?: string; + }>; +} + +/** + * HAL対応バッチダウンロードの推定サイズを取得 + * Storage Browser相当のフォルダ構成でのダウンロードサイズを推定 + * + * @param runIds - ダウンロード対象のランIDリスト + * @returns 推定サイズ情報 + */ +export const estimateBatchDownloadV2 = async ( + runIds: string[] +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/v2/storage/batch-download/estimate`, + { run_ids: runIds.map(id => parseInt(id, 10)) } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new APIError( + 'Failed to estimate batch download size', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +/** + * HAL対応: 複数ランのファイルをZIP形式で一括ダウンロード + * Storage Browser相当のフォルダ構成でダウンロード(全ストレージモード対応) + * + * @param runIds - ダウンロード対象のランIDリスト + * @param onProgress - 進捗コールバック(0-100%) + */ +export const downloadRunsAsZipV2 = async ( + runIds: string[], + onProgress?: (progress: number) => void +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/v2/storage/batch-download`, + { run_ids: runIds.map(id => parseInt(id, 10)) }, + { + responseType: 'blob', + onDownloadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(progress); + } else if (onProgress && progressEvent.loaded) { + // Totalがない場合は不定進捗(ストリーミング) + const estimatedTotal = 500 * 1024 * 1024; + const progress = Math.min(Math.round((progressEvent.loaded * 100) / estimatedTotal), 99); + onProgress(progress); + } + } + } + ); + + // Content-Dispositionからファイル名を取得 + const contentDisposition = response.headers['content-disposition']; + let filename = 'labcode_runs.zip'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (match) { + filename = match[1]; + } + } + + // Blobを作成してダウンロード + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + // 完了 + onProgress?.(100); + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + if (axiosError.response.data instanceof Blob) { + const text = await (axiosError.response.data as Blob).text(); + try { + const errorData = JSON.parse(text); + throw new APIError( + errorData.detail || 'Failed to download files', + axiosError.response.status, + errorData + ); + } catch { + throw new APIError( + 'Failed to download files', + axiosError.response.status + ); + } + } + throw new APIError( + 'Failed to download files', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } +}; + +/** + * 複数ランのメタデータダンプを一括ダウンロード + * 各ランごとに個別の.dbファイルをZIPにまとめてダウンロード + * + * @param runIds - ダウンロード対象のランIDリスト + * @param onProgress - 進捗コールバック(0-100%) + */ +export const downloadMetadataDumpsAsZip = async ( + runIds: string[], + onProgress?: (progress: number) => void +): Promise => { + try { + const response = await axios.post( + `${API_BASE_URL}/v2/storage/batch-dump`, + { run_ids: runIds.map(id => parseInt(id, 10)) }, + { + responseType: 'blob', + onDownloadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(progress); + } else if (onProgress && progressEvent.loaded) { + const estimatedTotal = 50 * 1024 * 1024; + const progress = Math.min(Math.round((progressEvent.loaded * 100) / estimatedTotal), 99); + onProgress(progress); + } + } + } + ); + + // Content-Dispositionからファイル名を取得 + const contentDisposition = response.headers['content-disposition']; + let filename = 'labcode_metadata_dumps.zip'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (match) { + filename = match[1]; + } + } + + // Blobを作成してダウンロード + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + // 完了 + onProgress?.(100); + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + if (axiosError.response.data instanceof Blob) { + const text = await (axiosError.response.data as Blob).text(); + try { + const errorData = JSON.parse(text); + throw new APIError( + errorData.detail || 'Failed to download metadata dumps', + axiosError.response.status, + errorData + ); + } catch { + throw new APIError( + 'Failed to download metadata dumps', + axiosError.response.status + ); + } + } + throw new APIError( + 'Failed to download metadata dumps', + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new APIError('No response received from API'); + } + } + throw new APIError(`Request setup error: ${(error as Error).message}`); + } }; \ No newline at end of file diff --git a/app/src/components/BatchDownloadButton.tsx b/app/src/components/BatchDownloadButton.tsx index 92427b5..ea77c3d 100644 --- a/app/src/components/BatchDownloadButton.tsx +++ b/app/src/components/BatchDownloadButton.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; -import { Download, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; -import { downloadRunsAsZip, estimateBatchDownload, APIError, BatchDownloadEstimate } from '../api/api'; +import React, { useState, useCallback } from 'react'; +import { Download, Loader2, AlertCircle, CheckCircle, Database, FolderArchive } from 'lucide-react'; +import { downloadRunsAsZipV2, downloadMetadataDumpsAsZip, estimateBatchDownloadV2, APIError, BatchDownloadV2Estimate } from '../api/api'; + +type DownloadFormat = 'zip' | 'db'; type DownloadStatus = 'idle' | 'estimating' | 'confirming' | 'downloading' | 'completed' | 'error'; @@ -32,8 +34,10 @@ export const BatchDownloadButton: React.FC = ({ }) => { const [status, setStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(null); - const [estimate, setEstimate] = useState(null); + const [estimate, setEstimate] = useState(null); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(0); + const [selectedFormat, setSelectedFormat] = useState('zip'); // ダウンロードが有効かどうか const canDownload = selectedRunIds.length > 0 && !isProcessing && status === 'idle'; @@ -44,44 +48,52 @@ export const BatchDownloadButton: React.FC = ({ const handleClick = async () => { if (!canDownload) return; - // Phase B: 確認ダイアログ対応 - if (needsConfirmation) { - setStatus('estimating'); - try { - const estimateResult = await estimateBatchDownload(selectedRunIds); - setEstimate(estimateResult); - - if (!estimateResult.can_download) { - setStatus('error'); - setErrorMessage(estimateResult.message || 'ダウンロードサイズが上限を超えています'); - return; - } - - setShowConfirmDialog(true); - setStatus('confirming'); - } catch (err) { - handleError(err); + // 常に形式選択ダイアログを表示 + setStatus('estimating'); + try { + const estimateResult = await estimateBatchDownloadV2(selectedRunIds); + setEstimate(estimateResult); + + if (!estimateResult.can_download) { + setStatus('error'); + setErrorMessage(estimateResult.message || 'ダウンロードサイズが上限を超えています'); + return; } - return; - } - // 直接ダウンロード - await executeDownload(); + setShowConfirmDialog(true); + setStatus('confirming'); + } catch (err) { + handleError(err); + } }; - const executeDownload = async () => { + const handleProgress = useCallback((progress: number) => { + setDownloadProgress(progress); + }, []); + + const executeDownload = async (format: DownloadFormat = selectedFormat) => { setStatus('downloading'); setErrorMessage(null); + setDownloadProgress(0); onDownloadStart?.(); try { - await downloadRunsAsZip(selectedRunIds); + // 選択された形式に応じてダウンロード + if (format === 'db') { + // Metadata Dump (.db) + await downloadMetadataDumpsAsZip(selectedRunIds, handleProgress); + } else { + // ZIP Archive (Storage Browser相当のフォルダ構成) + await downloadRunsAsZipV2(selectedRunIds, handleProgress); + } setStatus('completed'); + setDownloadProgress(100); onDownloadComplete?.(); // 3秒後にアイドル状態に戻す setTimeout(() => { setStatus('idle'); + setDownloadProgress(0); }, 3000); } catch (err) { handleError(err); @@ -115,15 +127,17 @@ export const BatchDownloadButton: React.FC = ({ onDownloadError?.(err instanceof Error ? err : new Error(message)); }; - const handleConfirm = () => { + const handleConfirm = (format: DownloadFormat) => { setShowConfirmDialog(false); - executeDownload(); + setSelectedFormat(format); + executeDownload(format); }; const handleCancel = () => { setShowConfirmDialog(false); setStatus('idle'); setEstimate(null); + setSelectedFormat('zip'); }; const handleRetry = () => { @@ -148,13 +162,24 @@ export const BatchDownloadButton: React.FC = ({ case 'downloading': return ( - +
+ +
+
+
+
+ {downloadProgress}% +
+
); case 'completed': @@ -206,39 +231,59 @@ export const BatchDownloadButton: React.FC = ({ <> {renderButton()} - {/* Phase B: 確認ダイアログ */} + {/* Phase B: 形式選択ダイアログ */} {showConfirmDialog && estimate && (

- ダウンロード確認 + ダウンロード形式を選択

{estimate.run_count}件のランをダウンロードします。

-

- 推定サイズ: 約 {estimate.estimated_size_mb.toFixed(1)}MB -

-

- ファイルサイズによっては時間がかかる場合があります。 +

+ 推定サイズ: 約 {estimate.estimated_size_mb.toFixed(1)}MB(ZIP Archive)

+ + {/* 形式選択オプション */} +
+ + + +
-
+
-
diff --git a/app/src/components/storage/DownloadFormatSelector.tsx b/app/src/components/storage/DownloadFormatSelector.tsx new file mode 100644 index 0000000..77a89a3 --- /dev/null +++ b/app/src/components/storage/DownloadFormatSelector.tsx @@ -0,0 +1,132 @@ +/** + * DownloadFormatSelector - ダウンロード形式選択コンポーネント + * + * Storage BrowserでRun単位のダウンロード形式を選択する。 + * - Metadata Dump (.db): SQLite形式のメタデータ + * - ZIP Archive: Storage Browser相当のフォルダ構成 + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { Download, Database, FolderArchive, ChevronDown, Loader2 } from 'lucide-react'; +import { downloadRunsAsZipV2 } from '../../api/api'; + +type DownloadFormat = 'db' | 'zip'; + +interface DownloadFormatSelectorProps { + runId: number; + disabled?: boolean; +} + +export const DownloadFormatSelector: React.FC = ({ + runId, + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloadFormat, setDownloadFormat] = useState(null); + const dropdownRef = useRef(null); + + // クリック外でドロップダウンを閉じる + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleDownload = async (format: DownloadFormat) => { + setIsOpen(false); + setDownloading(true); + setDownloadFormat(format); + + try { + if (format === 'db') { + // Metadata Dump (.db) + const link = document.createElement('a'); + link.href = `/log_server_api/v2/storage/dump/${runId}`; + link.download = `run_${runId}_dump.db`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + // ZIP Archive + await downloadRunsAsZipV2([runId.toString()]); + } + } catch (error) { + console.error('Download failed:', error); + alert('ダウンロードに失敗しました'); + } finally { + setDownloading(false); + setDownloadFormat(null); + } + }; + + return ( +
+ + + {/* ドロップダウンメニュー */} + {isOpen && !downloading && ( +
+
+ {/* Metadata Dump オプション */} + + +
+ + {/* ZIP Archive オプション */} + +
+
+ )} +
+ ); +}; + +export default DownloadFormatSelector; diff --git a/app/src/pages/OperationListPage.tsx b/app/src/pages/OperationListPage.tsx index e159f88..7da2aa0 100644 --- a/app/src/pages/OperationListPage.tsx +++ b/app/src/pages/OperationListPage.tsx @@ -59,7 +59,7 @@ export const OperationListPage: React.FC = () => { return (
-
+
diff --git a/app/src/pages/ProcessViewPage.tsx b/app/src/pages/ProcessViewPage.tsx index 82d7933..a4032db 100644 --- a/app/src/pages/ProcessViewPage.tsx +++ b/app/src/pages/ProcessViewPage.tsx @@ -84,7 +84,7 @@ export const ProcessViewPage: React.FC = () => { return (
-
+
diff --git a/app/src/pages/RunDetailPage.tsx b/app/src/pages/RunDetailPage.tsx index c65de6a..b697af2 100644 --- a/app/src/pages/RunDetailPage.tsx +++ b/app/src/pages/RunDetailPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useParams, Navigate, useNavigate } from 'react-router-dom'; -import { Database, Download, AlertTriangle } from 'lucide-react'; +import { AlertTriangle } from 'lucide-react'; import { UserProfile } from '../components/UserProfile'; import { Breadcrumbs } from '../components/Breadcrumbs'; import { useAuth } from '../contexts/AuthContext'; @@ -9,6 +9,7 @@ import { formatDateTime } from '../utils/dateFormatter'; import { fetchRun, fetchUser } from '../api/api'; import { RunResponse } from '../types/api'; import { FileBrowserV2 } from '../components/storage/FileBrowserV2'; +import { DownloadFormatSelector } from '../components/storage/DownloadFormatSelector'; import { StorageAddressLink } from '../components/common/StorageAddressLink'; import { isStorageModeUnknown } from '../utils/storageAddress'; import { getStorageInfoV2 } from '../services/runStorageService'; @@ -72,7 +73,7 @@ export const RunDetailPage: React.FC = () => { return (
-
+
{/*
*/}
@@ -192,18 +193,9 @@ export const RunDetailPage: React.FC = () => { run.storage_mode === 'local' ? 'Local Mode' : 'Unknown Mode'}

- {/* ローカルモード時のSQLダンプダウンロードボタン */} - {run.storage_mode === 'local' && ( - - - - SQL Dump - + {/* ダウンロード形式選択ボタン(全モード対応) */} + {run.id > 0 && ( + )}
diff --git a/app/src/pages/RunListPage.tsx b/app/src/pages/RunListPage.tsx index 3ae2050..3f8fe9a 100644 --- a/app/src/pages/RunListPage.tsx +++ b/app/src/pages/RunListPage.tsx @@ -126,7 +126,7 @@ export const RunListPage: React.FC = () => { return (
-
+
{/*
*/}
From cf32c5e885e601c670e6bbaf156cc2689fc5b171 Mon Sep 17 00:00:00 2001 From: Ayumu-Nono Date: Wed, 24 Dec 2025 16:07:20 +0900 Subject: [PATCH 3/4] feat: Add admin panel for user/project management and experiment execution - Add feature flag system (VITE_FEATURE_ADMIN_PANEL) - User management: create, delete users - Project management: create, edit, delete projects - Experiment execution: 4-step wizard UI - Dashboard with statistics - Improve Forbidden page with detailed explanation - Add navigation between Admin and Run List pages --- app/src/App.tsx | 6 + app/src/api/adminApi.ts | 260 +++++++++++++ app/src/components/admin/AdminHeader.tsx | 43 +++ app/src/components/admin/AdminLayout.tsx | 31 ++ app/src/components/admin/AdminSidebar.tsx | 127 +++++++ app/src/components/admin/ConfirmDialog.tsx | 110 ++++++ .../admin/ExperimentWizard/CompleteStep.tsx | 266 +++++++++++++ .../admin/ExperimentWizard/ConfigStep.tsx | 101 +++++ .../ExperimentWizard/ProjectSelectStep.tsx | 140 +++++++ .../admin/ExperimentWizard/RunningStep.tsx | 149 ++++++++ .../admin/ExperimentWizard/index.tsx | 217 +++++++++++ .../admin/ExperimentWizard/types.ts | 23 ++ app/src/components/admin/FileUploader.tsx | 109 ++++++ app/src/components/admin/ProjectForm.tsx | 199 ++++++++++ app/src/components/admin/StatsCard.tsx | 47 +++ app/src/components/admin/UserForm.tsx | 151 ++++++++ app/src/config/features.ts | 21 ++ app/src/pages/Forbidden.tsx | 93 ++++- app/src/pages/RunListPage.tsx | 19 +- app/src/pages/admin/ExperimentRunPage.tsx | 11 + app/src/pages/admin/ProjectsPage.tsx | 351 ++++++++++++++++++ app/src/pages/admin/UsersPage.tsx | 265 +++++++++++++ app/src/pages/admin/index.tsx | 197 ++++++++++ app/vite.config.ts | 11 + 24 files changed, 2942 insertions(+), 5 deletions(-) create mode 100644 app/src/api/adminApi.ts create mode 100644 app/src/components/admin/AdminHeader.tsx create mode 100644 app/src/components/admin/AdminLayout.tsx create mode 100644 app/src/components/admin/AdminSidebar.tsx create mode 100644 app/src/components/admin/ConfirmDialog.tsx create mode 100644 app/src/components/admin/ExperimentWizard/CompleteStep.tsx create mode 100644 app/src/components/admin/ExperimentWizard/ConfigStep.tsx create mode 100644 app/src/components/admin/ExperimentWizard/ProjectSelectStep.tsx create mode 100644 app/src/components/admin/ExperimentWizard/RunningStep.tsx create mode 100644 app/src/components/admin/ExperimentWizard/index.tsx create mode 100644 app/src/components/admin/ExperimentWizard/types.ts create mode 100644 app/src/components/admin/FileUploader.tsx create mode 100644 app/src/components/admin/ProjectForm.tsx create mode 100644 app/src/components/admin/StatsCard.tsx create mode 100644 app/src/components/admin/UserForm.tsx create mode 100644 app/src/config/features.ts create mode 100644 app/src/pages/admin/ExperimentRunPage.tsx create mode 100644 app/src/pages/admin/ProjectsPage.tsx create mode 100644 app/src/pages/admin/UsersPage.tsx create mode 100644 app/src/pages/admin/index.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 4b0f70f..4fc2a85 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,8 @@ import { OperationListPage } from './pages/OperationListPage'; import NotFound from './pages/NotFound'; import InternalServerError from './pages/InternalServerError'; import Forbidden from './pages/Forbidden'; +import { FEATURES } from './config/features'; +import { AdminRoutes } from './pages/admin'; // Redirect components for backward compatibility const RedirectToNewProcessesRoute = () => { @@ -36,6 +38,10 @@ function App() { } /> } /> } /> + {/* Admin routes - conditionally rendered based on feature flag */} + {FEATURES.ADMIN_PANEL && ( + } /> + )} {/* Redirect old URL patterns for backward compatibility */} } /> } /> diff --git a/app/src/api/adminApi.ts b/app/src/api/adminApi.ts new file mode 100644 index 0000000..cb2bc0c --- /dev/null +++ b/app/src/api/adminApi.ts @@ -0,0 +1,260 @@ +/** + * Admin API Client + * + * API functions for admin panel operations including: + * - User management (list, create, delete) + * - Project management (list, create, update, delete) + * - Experiment execution + */ + +import axios, { AxiosError } from 'axios'; + +const LOG_SERVER_API = '/log_server_api'; +const SIM_API = '/sim_api'; + +// ============================================================ +// Error Handling +// ============================================================ + +export class AdminAPIError extends Error { + constructor( + message: string, + public status?: number, + public data?: unknown + ) { + super(message); + this.name = 'AdminAPIError'; + } +} + +const handleError = (error: unknown, operation: string): never => { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new AdminAPIError( + `${operation} failed`, + axiosError.response.status, + axiosError.response.data + ); + } else if (axiosError.request) { + throw new AdminAPIError(`No response received during ${operation}`); + } + } + throw new AdminAPIError(`${operation} error: ${(error as Error).message}`); +}; + +// ============================================================ +// Types +// ============================================================ + +export interface User { + id: number; + email: string; +} + +export interface Project { + id: number; + name: string; + user_id: number; + owner_email?: string; + created_at?: string; + updated_at?: string; +} + +export interface RunExperimentResult { + run_id: number; + storage_address: string; + status: string; +} + +// ============================================================ +// User APIs +// ============================================================ + +/** + * List all users + * @param limit Maximum number of users to return + * @param offset Number of users to skip + * @returns Array of users + */ +export const listUsers = async ( + limit: number = 100, + offset: number = 0 +): Promise => { + try { + const response = await axios.get(`${LOG_SERVER_API}/users/list`, { + params: { limit, offset } + }); + return response.data; + } catch (error) { + return handleError(error, 'List users'); + } +}; + +/** + * Create a new user + * @param email User email address + * @returns Created user + */ +export const createUser = async (email: string): Promise => { + try { + const formData = new FormData(); + formData.append('email', email); + const response = await axios.post(`${LOG_SERVER_API}/users/`, formData); + return response.data; + } catch (error) { + return handleError(error, 'Create user'); + } +}; + +/** + * Delete a user + * @param id User ID + */ +export const deleteUser = async (id: number): Promise => { + try { + await axios.delete(`${LOG_SERVER_API}/users/${id}`); + } catch (error) { + return handleError(error, 'Delete user'); + } +}; + +/** + * Get projects for a specific user + * @param userId User ID + * @returns Array of projects + */ +export const getUserProjects = async (userId: number): Promise => { + try { + const response = await axios.get(`${LOG_SERVER_API}/users/${userId}/projects`); + return response.data; + } catch (error) { + return handleError(error, 'Get user projects'); + } +}; + +// ============================================================ +// Project APIs +// ============================================================ + +/** + * List all projects with owner information + * @param limit Maximum number of projects to return + * @param offset Number of projects to skip + * @returns Array of projects with owner email + */ +export const listProjects = async ( + limit: number = 100, + offset: number = 0 +): Promise => { + try { + const response = await axios.get(`${LOG_SERVER_API}/projects/list`, { + params: { limit, offset } + }); + return response.data; + } catch (error) { + return handleError(error, 'List projects'); + } +}; + +/** + * Create a new project + * @param name Project name + * @param userId Owner user ID + * @returns Created project + */ +export const createProject = async ( + name: string, + userId: number +): Promise => { + try { + const formData = new FormData(); + formData.append('name', name); + formData.append('user_id', userId.toString()); + const response = await axios.post(`${LOG_SERVER_API}/projects/`, formData); + return response.data; + } catch (error) { + return handleError(error, 'Create project'); + } +}; + +/** + * Update a project + * @param id Project ID + * @param name Project name + * @param userId Owner user ID + * @returns Updated project + */ +export const updateProject = async ( + id: number, + name: string, + userId: number +): Promise => { + try { + const formData = new FormData(); + formData.append('name', name); + formData.append('description', ''); + formData.append('user_id', userId.toString()); + const response = await axios.put(`${LOG_SERVER_API}/projects/${id}`, formData); + return response.data; + } catch (error) { + return handleError(error, 'Update project'); + } +}; + +/** + * Delete a project + * @param id Project ID + */ +export const deleteProject = async (id: number): Promise => { + try { + await axios.delete(`${LOG_SERVER_API}/projects/${id}`); + } catch (error) { + return handleError(error, 'Delete project'); + } +}; + +// ============================================================ +// Experiment APIs +// ============================================================ + +/** + * Run an experiment + * @param projectId Project ID + * @param protocolName Protocol name + * @param userId User ID + * @param protocolYaml Protocol YAML file + * @param manipulateYaml Manipulate YAML file + * @returns Experiment result + */ +export const runExperiment = async ( + projectId: number, + protocolName: string, + userId: number, + protocolYaml: File, + manipulateYaml: File +): Promise => { + try { + // Files are sent as FormData body, other params as query parameters + const formData = new FormData(); + formData.append('protocol_yaml', protocolYaml); + formData.append('manipulate_yaml', manipulateYaml); + + const response = await axios.post( + `${SIM_API}/run_experiment`, + formData, + { + params: { + project_id: projectId, + protocol_name: protocolName, + user_id: userId, + }, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 600000, // 10 minutes timeout + } + ); + return response.data; + } catch (error) { + return handleError(error, 'Run experiment'); + } +}; diff --git a/app/src/components/admin/AdminHeader.tsx b/app/src/components/admin/AdminHeader.tsx new file mode 100644 index 0000000..89ffef2 --- /dev/null +++ b/app/src/components/admin/AdminHeader.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useAuth } from '../../contexts/AuthContext'; + +interface AdminHeaderProps { + title?: string; +} + +export const AdminHeader: React.FC = ({ title }) => { + const { user, logout } = useAuth(); + + return ( +
+
+
+ {title && ( +

{title}

+ )} +
+
+ {user && ( +
+ {user.name} +
+

{user.name}

+

{user.email}

+
+ +
+ )} +
+
+
+ ); +}; diff --git a/app/src/components/admin/AdminLayout.tsx b/app/src/components/admin/AdminLayout.tsx new file mode 100644 index 0000000..ee27fb9 --- /dev/null +++ b/app/src/components/admin/AdminLayout.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { AdminSidebar } from './AdminSidebar'; +import { AdminHeader } from './AdminHeader'; +import { useAuth } from '../../contexts/AuthContext'; + +interface AdminLayoutProps { + children: React.ReactNode; + title?: string; +} + +export const AdminLayout: React.FC = ({ children, title }) => { + const { user } = useAuth(); + + // Redirect to login if not authenticated + if (!user) { + return ; + } + + return ( +
+ +
+ +
+ {children} +
+
+
+ ); +}; diff --git a/app/src/components/admin/AdminSidebar.tsx b/app/src/components/admin/AdminSidebar.tsx new file mode 100644 index 0000000..41dba90 --- /dev/null +++ b/app/src/components/admin/AdminSidebar.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +interface MenuItem { + path: string; + label: string; + icon: React.ReactNode; +} + +const menuItems: MenuItem[] = [ + { + path: '/admin', + label: 'Dashboard', + icon: ( + + + + ), + }, + { + path: '/admin/users', + label: 'Users', + icon: ( + + + + ), + }, + { + path: '/admin/projects', + label: 'Projects', + icon: ( + + + + ), + }, + { + path: '/admin/experiments/run', + label: 'Run Experiment', + icon: ( + + + + ), + }, +]; + +// Main app navigation items (external links) +const externalItems: MenuItem[] = [ + { + path: '/runs', + label: 'Run List', + icon: ( + + + + ), + }, +]; + +export const AdminSidebar: React.FC = () => { + return ( + + ); +}; diff --git a/app/src/components/admin/ConfirmDialog.tsx b/app/src/components/admin/ConfirmDialog.tsx new file mode 100644 index 0000000..bd5619e --- /dev/null +++ b/app/src/components/admin/ConfirmDialog.tsx @@ -0,0 +1,110 @@ +import React from 'react'; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + confirmButtonClass?: string; + onConfirm: () => void; + onCancel: () => void; + isLoading?: boolean; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + confirmButtonClass = 'bg-red-600 hover:bg-red-700', + onConfirm, + onCancel, + isLoading = false, +}) => { + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+
+ + + +
+

{title}

+
+ + {/* Message */} +

{message}

+ + {/* Buttons */} +
+ + +
+
+
+
+ ); +}; diff --git a/app/src/components/admin/ExperimentWizard/CompleteStep.tsx b/app/src/components/admin/ExperimentWizard/CompleteStep.tsx new file mode 100644 index 0000000..68ddbff --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/CompleteStep.tsx @@ -0,0 +1,266 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../../contexts/AuthContext'; +import { StepProps } from './types'; + +export const CompleteStep: React.FC = ({ + state, + setState, +}) => { + const navigate = useNavigate(); + const { user: loggedInUser } = useAuth(); + + const isSuccess = !state.error && state.runId !== null; + + // Check if the logged-in user can view the results + // (only the Run owner can view the Run details) + const canViewResults = isSuccess && + loggedInUser?.email === state.selectedUser?.email; + + const getDuration = () => { + if (state.startTime && state.endTime) { + const ms = state.endTime.getTime() - state.startTime.getTime(); + const seconds = Math.floor(ms / 1000); + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs.toString().padStart(2, '0')}s`; + } + return '-'; + }; + + const handleViewResults = () => { + if (state.runId && canViewResults) { + navigate(`/runs/${state.runId}`); + } + }; + + const handleRunAnother = () => { + setState({ + currentStep: 'project', + selectedProject: null, + selectedUser: null, + protocolName: '', + protocolFile: null, + manipulateFile: null, + runId: null, + error: null, + startTime: null, + endTime: null, + }); + }; + + const handleBackToDashboard = () => { + navigate('/admin'); + }; + + const handleGoToRunList = () => { + navigate('/runs'); + }; + + return ( +
+
+ {isSuccess ? ( + <> +
+
+ + + +
+
+ +

+ Experiment Completed! +

+ +
+
+
+

Run ID

+

+ {state.runId} +

+
+
+

Duration

+

+ {getDuration()} +

+
+
+

Owner

+

+ {state.selectedUser?.email || '-'} +

+
+
+

Status

+

+ Completed +

+
+
+
+ + {/* Warning when logged-in user is different from Run owner */} + {isSuccess && !canViewResults && ( +
+
+ + + +
+

+ Cannot view results directly +

+

+ This Run belongs to {state.selectedUser?.email}. + You are logged in as {loggedInUser?.email}. + To view this Run's details, please log in as the owner. +

+
+
+
+ )} + + ) : ( + <> +
+
+ + + +
+
+ +

+ Experiment Failed +

+ +
+

+ {state.error || 'An unknown error occurred'} +

+
+ + )} + +
+ {isSuccess && canViewResults && ( + + )} + + {isSuccess && !canViewResults && ( + + )} + + + +
+
+
+ ); +}; diff --git a/app/src/components/admin/ExperimentWizard/ConfigStep.tsx b/app/src/components/admin/ExperimentWizard/ConfigStep.tsx new file mode 100644 index 0000000..872ea8f --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/ConfigStep.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { StepProps } from './types'; +import { FileUploader } from '../FileUploader'; + +export const ConfigStep: React.FC = ({ + state, + setState, +}) => { + const handleBack = () => { + setState((prev) => ({ ...prev, currentStep: 'project' })); + }; + + const handleRunExperiment = () => { + setState((prev) => ({ + ...prev, + currentStep: 'running', + startTime: new Date(), + })); + }; + + const isValid = + state.protocolName.trim() !== '' && + state.protocolFile !== null && + state.manipulateFile !== null; + + return ( +
+
+

+ Configure Experiment +

+ + {/* Project Info */} +
+

+ Project: {state.selectedProject?.name} +

+

+ Owner: {state.selectedUser?.email} +

+
+ + {/* Protocol Name */} +
+ + + setState((prev) => ({ ...prev, protocolName: e.target.value })) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="experiment_2025_01" + /> +
+ + {/* Protocol YAML */} + + setState((prev) => ({ ...prev, protocolFile: file })) + } + required + /> + + {/* Manipulate YAML */} + + setState((prev) => ({ ...prev, manipulateFile: file })) + } + required + /> +
+ +
+ + +
+
+ ); +}; diff --git a/app/src/components/admin/ExperimentWizard/ProjectSelectStep.tsx b/app/src/components/admin/ExperimentWizard/ProjectSelectStep.tsx new file mode 100644 index 0000000..fea066e --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/ProjectSelectStep.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { StepProps } from './types'; +import { ProjectForm } from '../ProjectForm'; +import { createProject } from '../../../api/adminApi'; + +export const ProjectSelectStep: React.FC = ({ + state, + setState, + users, + projects, +}) => { + const [isFormOpen, setIsFormOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [localProjects, setLocalProjects] = useState(projects); + + const handleProjectSelect = (projectId: number) => { + const project = localProjects.find((p) => p.id === projectId); + const user = users.find((u) => u.id === project?.user_id); + setState((prev) => ({ + ...prev, + selectedProject: project || null, + selectedUser: user || null, + })); + }; + + const handleCreateProject = async (name: string, userId: number) => { + setIsCreating(true); + try { + const newProject = await createProject(name, userId); + const user = users.find((u) => u.id === userId); + setLocalProjects((prev) => [...prev, { ...newProject, owner_email: user?.email }]); + setState((prev) => ({ + ...prev, + selectedProject: { ...newProject, owner_email: user?.email }, + selectedUser: user || null, + })); + } finally { + setIsCreating(false); + } + }; + + const handleNext = () => { + if (state.selectedProject) { + setState((prev) => ({ ...prev, currentStep: 'config' })); + } + }; + + const handleCancel = () => { + setState((prev) => ({ + ...prev, + selectedProject: null, + selectedUser: null, + protocolName: '', + protocolFile: null, + manipulateFile: null, + currentStep: 'project', + })); + }; + + return ( +
+
+

+ Select Project +

+ +
+ + +
+ + {state.selectedProject && ( +
+

+ Selected: {state.selectedProject.name} +

+

+ Owner: {state.selectedUser?.email || 'Unknown'} +

+
+ )} + +
+ + or + +
+ + +
+ +
+ + +
+ + setIsFormOpen(false)} + onSubmit={handleCreateProject} + users={users} + isLoading={isCreating} + /> +
+ ); +}; diff --git a/app/src/components/admin/ExperimentWizard/RunningStep.tsx b/app/src/components/admin/ExperimentWizard/RunningStep.tsx new file mode 100644 index 0000000..6eef906 --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/RunningStep.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +import { StepProps } from './types'; +import { runExperiment, AdminAPIError } from '../../../api/adminApi'; + +export const RunningStep: React.FC = ({ + state, + setState, +}) => { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + // Run experiment on mount + useEffect(() => { + let isMounted = true; + + const executeExperiment = async () => { + if ( + !state.selectedProject || + !state.selectedUser || + !state.protocolFile || + !state.manipulateFile + ) { + setState((prev) => ({ + ...prev, + error: 'Missing required data for experiment', + currentStep: 'config', + })); + return; + } + + try { + const result = await runExperiment( + state.selectedProject.id, + state.protocolName, + state.selectedUser.id, + state.protocolFile, + state.manipulateFile + ); + + if (isMounted) { + setState((prev) => ({ + ...prev, + runId: result.run_id, + endTime: new Date(), + currentStep: 'complete', + })); + } + } catch (err) { + if (isMounted) { + const errorMessage = + err instanceof AdminAPIError + ? err.message + : 'Experiment failed'; + setState((prev) => ({ + ...prev, + error: errorMessage, + endTime: new Date(), + currentStep: 'complete', + })); + } + } + }; + + executeExperiment(); + + return () => { + isMounted = false; + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update elapsed time + useEffect(() => { + const interval = setInterval(() => { + if (state.startTime) { + const now = new Date(); + const elapsed = Math.floor( + (now.getTime() - state.startTime.getTime()) / 1000 + ); + setElapsedSeconds(elapsed); + } + }, 1000); + + return () => clearInterval(interval); + }, [state.startTime]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs.toString().padStart(2, '0')}s`; + }; + + return ( +
+
+
+ + + + +
+ +

+ Running Experiment... +

+ +

+ Please wait while the experiment is being executed. +

+ +
+

+ Elapsed: {formatTime(elapsedSeconds)} +

+
+ +
+

+ Project: {state.selectedProject?.name} +

+

+ Protocol: {state.protocolName} +

+

+ User: {state.selectedUser?.email} +

+
+
+ +
+

This may take several minutes depending on the protocol complexity.

+

Do not close this page while the experiment is running.

+
+
+ ); +}; diff --git a/app/src/components/admin/ExperimentWizard/index.tsx b/app/src/components/admin/ExperimentWizard/index.tsx new file mode 100644 index 0000000..3ca16cd --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/index.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react'; +import { WizardState } from './types'; +import { ProjectSelectStep } from './ProjectSelectStep'; +import { ConfigStep } from './ConfigStep'; +import { RunningStep } from './RunningStep'; +import { CompleteStep } from './CompleteStep'; +import { listUsers, listProjects, User, Project, AdminAPIError } from '../../../api/adminApi'; + +const steps = [ + { key: 'project', label: 'Select Project' }, + { key: 'config', label: 'Configure' }, + { key: 'running', label: 'Running' }, + { key: 'complete', label: 'Complete' }, +] as const; + +export const ExperimentWizard: React.FC = () => { + const [users, setUsers] = useState([]); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [state, setState] = useState({ + currentStep: 'project', + selectedProject: null, + selectedUser: null, + protocolName: '', + protocolFile: null, + manipulateFile: null, + runId: null, + error: null, + startTime: null, + endTime: null, + }); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const [usersData, projectsData] = await Promise.all([ + listUsers(), + listProjects(), + ]); + setUsers(usersData); + setProjects(projectsData); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to load data'); + } + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const currentStepIndex = steps.findIndex((s) => s.key === state.currentStep); + + if (loading) { + return ( +
+ + + + + Loading... +
+ ); + } + + if (error) { + return ( +
+
+
+ + + +
+
+

{error}

+
+ ); + } + + return ( +
+ {/* Progress Steps */} +
+
+ {steps.map((step, index) => ( + +
+
+ {index < currentStepIndex ? ( + + + + ) : ( + index + 1 + )} +
+ + {step.label} + +
+ {index < steps.length - 1 && ( +
+ )} + + ))} +
+
+ + {/* Step Content */} +
+ {state.currentStep === 'project' && ( + + )} + {state.currentStep === 'config' && ( + + )} + {state.currentStep === 'running' && ( + + )} + {state.currentStep === 'complete' && ( + + )} +
+
+ ); +}; + +export default ExperimentWizard; diff --git a/app/src/components/admin/ExperimentWizard/types.ts b/app/src/components/admin/ExperimentWizard/types.ts new file mode 100644 index 0000000..3baf003 --- /dev/null +++ b/app/src/components/admin/ExperimentWizard/types.ts @@ -0,0 +1,23 @@ +import { Project, User } from '../../../api/adminApi'; + +export type WizardStep = 'project' | 'config' | 'running' | 'complete'; + +export interface WizardState { + currentStep: WizardStep; + selectedProject: Project | null; + selectedUser: User | null; + protocolName: string; + protocolFile: File | null; + manipulateFile: File | null; + runId: number | null; + error: string | null; + startTime: Date | null; + endTime: Date | null; +} + +export interface StepProps { + state: WizardState; + setState: React.Dispatch>; + users: User[]; + projects: Project[]; +} diff --git a/app/src/components/admin/FileUploader.tsx b/app/src/components/admin/FileUploader.tsx new file mode 100644 index 0000000..3507048 --- /dev/null +++ b/app/src/components/admin/FileUploader.tsx @@ -0,0 +1,109 @@ +import React, { useRef } from 'react'; + +interface FileUploaderProps { + label: string; + accept?: string; + file: File | null; + onChange: (file: File | null) => void; + disabled?: boolean; + required?: boolean; +} + +export const FileUploader: React.FC = ({ + label, + accept = '.yaml,.yml', + file, + onChange, + disabled = false, + required = false, +}) => { + const inputRef = useRef(null); + + const handleClick = () => { + if (!disabled) { + inputRef.current?.click(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0] || null; + onChange(selectedFile); + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(null); + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (disabled) return; + + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + const extension = droppedFile.name.toLowerCase().split('.').pop(); + if (extension === 'yaml' || extension === 'yml') { + onChange(droppedFile); + } + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + return ( +
+ + +
+ {file ? ( +
+ + + + {file.name} + +
+ ) : ( +
+ + + +

Drop file here or click to browse

+

YAML files only

+
+ )} +
+
+ ); +}; diff --git a/app/src/components/admin/ProjectForm.tsx b/app/src/components/admin/ProjectForm.tsx new file mode 100644 index 0000000..b0dc437 --- /dev/null +++ b/app/src/components/admin/ProjectForm.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect } from 'react'; +import { User, Project } from '../../api/adminApi'; + +interface ProjectFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (name: string, userId: number) => Promise; + users: User[]; + editProject?: Project | null; + isLoading?: boolean; +} + +export const ProjectForm: React.FC = ({ + isOpen, + onClose, + onSubmit, + users, + editProject = null, + isLoading = false, +}) => { + const [name, setName] = useState(''); + const [userId, setUserId] = useState(''); + const [error, setError] = useState(null); + + // Initialize form with edit project data + useEffect(() => { + if (editProject) { + setName(editProject.name); + setUserId(editProject.user_id); + } else { + setName(''); + setUserId(''); + } + }, [editProject, isOpen]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Validate + if (!name.trim()) { + setError('Project name is required'); + return; + } + if (userId === '') { + setError('Please select an owner'); + return; + } + + try { + await onSubmit(name.trim(), userId); + setName(''); + setUserId(''); + onClose(); + } catch (err) { + setError((err as Error).message || 'Failed to save project'); + } + }; + + const handleClose = () => { + setName(''); + setUserId(''); + setError(null); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+

+ {editProject ? 'Edit Project' : 'Create New Project'} +

+ +
+ + {/* Form */} +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" + placeholder="My Experiment Project" + required + disabled={isLoading} + /> +
+ +
+ + + {users.length === 0 && ( +

+ No users available. Please create a user first. +

+ )} +
+ + {error && ( +
+

{error}

+
+ )} + + {/* Buttons */} +
+ + +
+
+
+
+
+ ); +}; diff --git a/app/src/components/admin/StatsCard.tsx b/app/src/components/admin/StatsCard.tsx new file mode 100644 index 0000000..5cc7713 --- /dev/null +++ b/app/src/components/admin/StatsCard.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +interface StatsCardProps { + title: string; + value: number | string; + icon: React.ReactNode; + iconBgColor: string; + link?: string; + loading?: boolean; +} + +export const StatsCard: React.FC = ({ + title, + value, + icon, + iconBgColor, + link, + loading = false, +}) => { + const content = ( +
+
{icon}
+
+

{title}

+ {loading ? ( +
+ ) : ( +

{value}

+ )} +
+
+ ); + + if (link) { + return ( + + {content} + + ); + } + + return
{content}
; +}; diff --git a/app/src/components/admin/UserForm.tsx b/app/src/components/admin/UserForm.tsx new file mode 100644 index 0000000..9f06b6d --- /dev/null +++ b/app/src/components/admin/UserForm.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; + +interface UserFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (email: string) => Promise; + isLoading?: boolean; +} + +export const UserForm: React.FC = ({ + isOpen, + onClose, + onSubmit, + isLoading = false, +}) => { + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Validate email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setError('Please enter a valid email address'); + return; + } + + try { + await onSubmit(email); + setEmail(''); + onClose(); + } catch (err) { + setError((err as Error).message || 'Failed to create user'); + } + }; + + const handleClose = () => { + setEmail(''); + setError(null); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+

+ Create New User +

+ +
+ + {/* Form */} +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="user@example.com" + required + disabled={isLoading} + /> +

+ User will be able to login with Google OAuth using this email +

+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Buttons */} +
+ + +
+
+
+
+
+ ); +}; diff --git a/app/src/config/features.ts b/app/src/config/features.ts new file mode 100644 index 0000000..da84132 --- /dev/null +++ b/app/src/config/features.ts @@ -0,0 +1,21 @@ +/** + * Feature Flags for conditional feature enabling + * + * Features can be enabled/disabled via environment variables. + * This allows for safe rollout of new features and easy removal if needed. + */ +export const FEATURES = { + /** + * Admin Panel - User and Project management UI + * Enable: VITE_FEATURE_ADMIN_PANEL=true + */ + ADMIN_PANEL: import.meta.env.VITE_FEATURE_ADMIN_PANEL === 'true', + + /** + * Experiment Runner - UI for running experiments + * Enable: VITE_FEATURE_EXPERIMENT_RUNNER=true + */ + EXPERIMENT_RUNNER: import.meta.env.VITE_FEATURE_EXPERIMENT_RUNNER === 'true', +} as const; + +export type FeatureFlags = typeof FEATURES; diff --git a/app/src/pages/Forbidden.tsx b/app/src/pages/Forbidden.tsx index 315d21e..30ec28f 100644 --- a/app/src/pages/Forbidden.tsx +++ b/app/src/pages/Forbidden.tsx @@ -1,9 +1,96 @@ +import { useNavigate } from 'react-router-dom'; +import { FEATURES } from '../config/features'; + export default function Forbidden() { + const navigate = useNavigate(); return ( -
-

403

-

Forbidden

+
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Error Code */} +

403

+

Access Denied

+ + {/* Explanation */} +
+

+ Why am I seeing this? +

+
    +
  • + + + + + Access to other users' data: The Run you are trying to view belongs to a different user. For security reasons, you cannot access another user's experiment data. + +
  • +
  • + + + + + Admin experiments: If you ran an experiment from the Admin panel using a different user account, the Run belongs to that user, not you. + +
  • +
+
+ + {/* Actions */} +
+ + + {FEATURES.ADMIN_PANEL && ( + + )} + + +
+
); } diff --git a/app/src/pages/RunListPage.tsx b/app/src/pages/RunListPage.tsx index 3f8fe9a..e51da03 100644 --- a/app/src/pages/RunListPage.tsx +++ b/app/src/pages/RunListPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; +import { Navigate, useNavigate, Link } from 'react-router-dom'; import { DataTable } from '../components/DataTable'; import { UserProfile } from '../components/UserProfile'; import { Breadcrumbs } from '../components/Breadcrumbs'; @@ -13,6 +13,7 @@ import { fetchRuns, updateRunVisibility } from '../api/api'; // import { RunsResponse } from '../types/api'; import { APIError } from '../api/api'; import { DataItem } from '../types/data'; +import { FEATURES } from '../config/features'; // import { mockData } from '../data/mockData'; export const RunListPage: React.FC = () => { @@ -131,7 +132,21 @@ export const RunListPage: React.FC = () => {
-

Run list

+
+

Run list

+ {FEATURES.ADMIN_PANEL && ( + + + + + + Admin + + )} +
diff --git a/app/src/pages/admin/ExperimentRunPage.tsx b/app/src/pages/admin/ExperimentRunPage.tsx new file mode 100644 index 0000000..750b772 --- /dev/null +++ b/app/src/pages/admin/ExperimentRunPage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { AdminLayout } from '../../components/admin/AdminLayout'; +import { ExperimentWizard } from '../../components/admin/ExperimentWizard'; + +export const ExperimentRunPage: React.FC = () => { + return ( + + + + ); +}; diff --git a/app/src/pages/admin/ProjectsPage.tsx b/app/src/pages/admin/ProjectsPage.tsx new file mode 100644 index 0000000..ceb687d --- /dev/null +++ b/app/src/pages/admin/ProjectsPage.tsx @@ -0,0 +1,351 @@ +import React, { useState, useEffect } from 'react'; +import { AdminLayout } from '../../components/admin/AdminLayout'; +import { ProjectForm } from '../../components/admin/ProjectForm'; +import { ConfirmDialog } from '../../components/admin/ConfirmDialog'; +import { + listProjects, + listUsers, + createProject, + updateProject, + deleteProject, + Project, + User, + AdminAPIError, +} from '../../api/adminApi'; + +export const ProjectsPage: React.FC = () => { + const [projects, setProjects] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + // Form state + const [isFormOpen, setIsFormOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Delete confirmation state + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Fetch data on mount + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const [projectsData, usersData] = await Promise.all([ + listProjects(), + listUsers(), + ]); + setProjects(projectsData); + setUsers(usersData); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to load data'); + } + } finally { + setLoading(false); + } + }; + + const handleCreateProject = async (name: string, userId: number) => { + setIsSaving(true); + try { + await createProject(name, userId); + await fetchData(); + } finally { + setIsSaving(false); + } + }; + + const handleUpdateProject = async (name: string, userId: number) => { + if (!editTarget) return; + setIsSaving(true); + try { + await updateProject(editTarget.id, name, userId); + await fetchData(); + setEditTarget(null); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteProject = async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + try { + await deleteProject(deleteTarget.id); + await fetchData(); + setDeleteTarget(null); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to delete project'); + } + } finally { + setIsDeleting(false); + } + }; + + const openEditForm = (project: Project) => { + setEditTarget(project); + setIsFormOpen(true); + }; + + const closeForm = () => { + setIsFormOpen(false); + setEditTarget(null); + }; + + // Filter projects based on search term + const filteredProjects = projects.filter( + (project) => + project.name.toLowerCase().includes(searchTerm.toLowerCase()) || + project.owner_email?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + +
+ {/* Header */} +
+
+

+ Project Management +

+
+ {/* Search */} +
+ + + + setSearchTerm(e.target.value)} + className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> +
+ {/* New Project Button */} + +
+
+
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Table */} +
+ {loading ? ( +
+ + + + + Loading projects... +
+ ) : filteredProjects.length === 0 ? ( +
+ {searchTerm ? ( +

No projects found matching "{searchTerm}"

+ ) : ( +

No projects found. Create your first project to get started.

+ )} +
+ ) : ( + + + + + + + + + + + + {filteredProjects.map((project) => ( + + + + + + + + ))} + +
+ ID + + Name + + Owner + + Created + + Actions +
+ {project.id} + +
+
+ + + +
+
+

+ {project.name} +

+
+
+
+ {project.owner_email || '-'} + + {project.created_at + ? new Date(project.created_at).toLocaleDateString() + : '-'} + +
+ + +
+
+ )} +
+ + {/* Footer */} +
+

+ Showing {filteredProjects.length} of {projects.length} projects +

+
+
+ + {/* Create/Edit Project Form */} + + + {/* Delete Confirmation Dialog */} + setDeleteTarget(null)} + isLoading={isDeleting} + /> +
+ ); +}; diff --git a/app/src/pages/admin/UsersPage.tsx b/app/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..3e11178 --- /dev/null +++ b/app/src/pages/admin/UsersPage.tsx @@ -0,0 +1,265 @@ +import React, { useState, useEffect } from 'react'; +import { AdminLayout } from '../../components/admin/AdminLayout'; +import { UserForm } from '../../components/admin/UserForm'; +import { ConfirmDialog } from '../../components/admin/ConfirmDialog'; +import { listUsers, createUser, deleteUser, User, AdminAPIError } from '../../api/adminApi'; + +export const UsersPage: React.FC = () => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + // Form state + const [isFormOpen, setIsFormOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + // Delete confirmation state + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Fetch users on mount + useEffect(() => { + fetchUsers(); + }, []); + + const fetchUsers = async () => { + setLoading(true); + setError(null); + try { + const data = await listUsers(); + setUsers(data); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to load users'); + } + } finally { + setLoading(false); + } + }; + + const handleCreateUser = async (email: string) => { + setIsCreating(true); + try { + await createUser(email); + await fetchUsers(); + } finally { + setIsCreating(false); + } + }; + + const handleDeleteUser = async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + try { + await deleteUser(deleteTarget.id); + await fetchUsers(); + setDeleteTarget(null); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to delete user'); + } + } finally { + setIsDeleting(false); + } + }; + + // Filter users based on search term + const filteredUsers = users.filter((user) => + user.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + +
+ {/* Header */} +
+
+

+ User Management +

+
+ {/* Search */} +
+ + + + setSearchTerm(e.target.value)} + className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ {/* New User Button */} + +
+
+
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Table */} +
+ {loading ? ( +
+ + + + + Loading users... +
+ ) : filteredUsers.length === 0 ? ( +
+ {searchTerm ? ( +

No users found matching "{searchTerm}"

+ ) : ( +

No users found. Create your first user to get started.

+ )} +
+ ) : ( + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + ))} + +
+ ID + + Email + + Actions +
+ {user.id} + +
+
+ + {user.email.charAt(0).toUpperCase()} + +
+
+

+ {user.email} +

+
+
+
+ +
+ )} +
+ + {/* Footer */} +
+

+ Showing {filteredUsers.length} of {users.length} users +

+
+
+ + {/* Create User Form */} + setIsFormOpen(false)} + onSubmit={handleCreateUser} + isLoading={isCreating} + /> + + {/* Delete Confirmation Dialog */} + setDeleteTarget(null)} + isLoading={isDeleting} + /> +
+ ); +}; diff --git a/app/src/pages/admin/index.tsx b/app/src/pages/admin/index.tsx new file mode 100644 index 0000000..16e9fdc --- /dev/null +++ b/app/src/pages/admin/index.tsx @@ -0,0 +1,197 @@ +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Link } from 'react-router-dom'; +import { AdminLayout } from '../../components/admin/AdminLayout'; +import { StatsCard } from '../../components/admin/StatsCard'; +import { UsersPage } from './UsersPage'; +import { ProjectsPage } from './ProjectsPage'; +import { ExperimentRunPage } from './ExperimentRunPage'; +import { listUsers, listProjects, AdminAPIError } from '../../api/adminApi'; + +interface Stats { + userCount: number; + projectCount: number; + runCount: number; +} + +// Dashboard component +const AdminDashboard: React.FC = () => { + const [stats, setStats] = useState({ userCount: 0, projectCount: 0, runCount: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + try { + const [users, projects] = await Promise.all([ + listUsers(), + listProjects(), + ]); + setStats({ + userCount: users.length, + projectCount: projects.length, + runCount: 0, // Run count would need a separate API + }); + } catch (err) { + if (err instanceof AdminAPIError) { + setError(err.message); + } else { + setError('Failed to load statistics'); + } + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, []); + + return ( + +
+
+

+ Welcome to Admin Panel +

+

+ Manage users, projects, and run experiments from this dashboard. +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + + + } + iconBgColor="bg-blue-100" + link="/admin/users" + loading={loading} + /> + + + + + } + iconBgColor="bg-green-100" + link="/admin/projects" + loading={loading} + /> + + + + + } + iconBgColor="bg-purple-100" + link="/runs" + loading={loading} + /> +
+ +
+

Quick Actions

+
+ + + + + New User + + + + + + New Project + + + + + + + Run Experiment + + + + + + View Run List + +
+
+ +
+

Getting Started

+
+
+ + 1 + +

+ Create a user - Add users who can login and run experiments +

+
+
+ + 2 + +

+ Create a project - Organize experiments under projects +

+
+
+ + 3 + +

+ Run an experiment - Upload protocol files and execute +

+
+
+
+
+
+ ); +}; + +// Admin routes wrapper +export const AdminRoutes: React.FC = () => { + return ( + + } /> + } /> + } /> + } /> + + ); +}; + +export default AdminRoutes; diff --git a/app/vite.config.ts b/app/vite.config.ts index 9999288..30bcc1a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -25,6 +25,17 @@ export default defineConfig({ proxyRes.headers['Access-Control-Allow-Origin'] = '*'; }); } + }, + // Proxy for labcode-sim experiment runner + '/sim_api': { + target: 'http://lab_simulator:8080', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/sim_api/, ''), + configure: (proxy, _options) => { + proxy.on('proxyRes', (proxyRes, _req, _res) => { + proxyRes.headers['Access-Control-Allow-Origin'] = '*'; + }); + } } } } From ecc5426acfc074404073f9f2e69a4f1cc1151fe4 Mon Sep 17 00:00:00 2001 From: Ayumu-Nono Date: Wed, 24 Dec 2025 23:24:49 +0900 Subject: [PATCH 4/4] fix: Prevent double API call in React StrictMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hasExecutedRef to prevent duplicate experiment execution - Add isMountedRef to handle component remount correctly - Move isMountedRef reset before early return for proper StrictMode behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../admin/ExperimentWizard/RunningStep.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/components/admin/ExperimentWizard/RunningStep.tsx b/app/src/components/admin/ExperimentWizard/RunningStep.tsx index 6eef906..4baf30a 100644 --- a/app/src/components/admin/ExperimentWizard/RunningStep.tsx +++ b/app/src/components/admin/ExperimentWizard/RunningStep.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { StepProps } from './types'; import { runExperiment, AdminAPIError } from '../../../api/adminApi'; @@ -7,10 +7,17 @@ export const RunningStep: React.FC = ({ setState, }) => { const [elapsedSeconds, setElapsedSeconds] = useState(0); + const hasExecutedRef = useRef(false); + const isMountedRef = useRef(true); // Run experiment on mount useEffect(() => { - let isMounted = true; + // Always reset isMountedRef on mount (including StrictMode remount) + isMountedRef.current = true; + + // Prevent double API call in React StrictMode + if (hasExecutedRef.current) return; + hasExecutedRef.current = true; const executeExperiment = async () => { if ( @@ -36,7 +43,7 @@ export const RunningStep: React.FC = ({ state.manipulateFile ); - if (isMounted) { + if (isMountedRef.current) { setState((prev) => ({ ...prev, runId: result.run_id, @@ -45,7 +52,7 @@ export const RunningStep: React.FC = ({ })); } } catch (err) { - if (isMounted) { + if (isMountedRef.current) { const errorMessage = err instanceof AdminAPIError ? err.message @@ -63,7 +70,7 @@ export const RunningStep: React.FC = ({ executeExperiment(); return () => { - isMounted = false; + isMountedRef.current = false; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps