Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ae6e3f43 | |||
| 556f5866a7 | |||
| c7a6693ce4 | |||
| 15d993555f | |||
| 39b55ad479 | |||
| 1b49256af6 | |||
| 81fc8f842a | |||
| 44c6c08a02 | |||
| 38a13f29fd | |||
| 840fa0a604 | |||
| c594c1cb85 | |||
| e4b968e5dd | |||
| 48b81dd5cf | |||
| 3b471245c7 | |||
| 5ebab95a8b | |||
| 8acbb760c8 | |||
| 8d37cf86a2 | |||
| 310daaa424 | |||
| d08a1a1a79 | |||
| c4c2a845ea | |||
| e1d9d803d5 | |||
| 79e2e8443d | |||
| 86e4330066 | |||
| 5ec75d68ab | |||
| 5bae18678c | |||
| 641166ba27 | |||
| e6bbaf4971 | |||
| 039a2ca285 | |||
| c345dade10 | |||
| 50e9b1d5ee | |||
| e08ae4639b | |||
| 3bb54af459 | |||
| 99f362fda1 | |||
| 622fe6e4b3 | |||
| bbda7887ee | |||
| e2bb9075bd | |||
| f6284aef79 | |||
| a9c7a3654b | |||
| 8c7c26e9c0 | |||
| 070426620e | |||
| 7e56b93f01 | |||
| 369de72e98 | |||
| 052b87e4ff | |||
| 20d5ccef00 | |||
| b2cfd84f10 | |||
| 6da6e2a980 | |||
| 1118ff5663 | |||
| d529aa6f19 | |||
| e55d2f536a | |||
| f6d9c8d0bb | |||
| b6d1ba743b | |||
| d5516c6cf8 | |||
| 92c0cf414b | |||
| 718ce8d09f | |||
| a9a9024830 | |||
| f7dfce5918 | |||
| c6d3ff8f5c | |||
| ded18b44e5 |
Generated
+2
-399
@@ -15,7 +15,6 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
"recharts": "^3.9.2",
|
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
@@ -790,32 +789,6 @@
|
|||||||
"integrity": "sha512-XSJlaNzj3j+uSNfGkkkxw6hyKw+d8fuZd/N1/NrxvTJIiUhVMXQ1PE30JRu5yWzbvmezKSCmloz8wbq4sWsrFw==",
|
"integrity": "sha512-XSJlaNzj3j+uSNfGkkkxw6hyKw+d8fuZd/N1/NrxvTJIiUhVMXQ1PE30JRu5yWzbvmezKSCmloz8wbq4sWsrFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
|
||||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^11.0.0",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1186,18 +1159,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||||
@@ -1568,69 +1529,6 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-array": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-color": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-ease": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-interpolate": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-color": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-path": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-time": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-shape": {
|
|
||||||
"version": "3.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
|
||||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-path": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1641,7 +1539,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1657,12 +1555,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1766,15 +1658,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1798,130 +1681,9 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1939,12 +1701,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1973,16 +1729,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.49.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
|
||||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2033,12 +1779,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2085,25 +1825,6 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "11.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
|
||||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2535,36 +2256,6 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "19.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
|
||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
|
||||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -2612,57 +2303,6 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
|
||||||
"version": "3.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
|
|
||||||
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"www"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"decimal.js-light": "^2.5.1",
|
|
||||||
"es-toolkit": "^1.39.3",
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"immer": "^11.1.8",
|
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
|
||||||
"reselect": "5.2.0",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.0",
|
"version": "4.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
||||||
@@ -2756,12 +2396,6 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tiny-invariant": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -2822,37 +2456,6 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/victory-vendor": {
|
|
||||||
"version": "37.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
|
||||||
"license": "MIT AND ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "^3.0.3",
|
|
||||||
"@types/d3-ease": "^3.0.0",
|
|
||||||
"@types/d3-interpolate": "^3.0.1",
|
|
||||||
"@types/d3-scale": "^4.0.2",
|
|
||||||
"@types/d3-shape": "^3.1.0",
|
|
||||||
"@types/d3-time": "^3.0.0",
|
|
||||||
"@types/d3-timer": "^3.0.0",
|
|
||||||
"d3-array": "^3.1.6",
|
|
||||||
"d3-ease": "^3.0.1",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-shape": "^3.1.0",
|
|
||||||
"d3-time": "^3.0.0",
|
|
||||||
"d3-timer": "^3.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
"recharts": "^3.9.2",
|
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
+82
-796
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type CommitAuthorUser = {
|
|
||||||
login?: string;
|
|
||||||
username?: string;
|
|
||||||
full_name?: string;
|
|
||||||
name?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
} | null | undefined;
|
|
||||||
|
|
||||||
export type CommitAuthorLike = {
|
|
||||||
commit?: {
|
|
||||||
author?: {
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
author?: CommitAuthorUser;
|
|
||||||
committer?: CommitAuthorUser;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function commitAuthorName(commit: CommitAuthorLike) {
|
|
||||||
return commit.author?.login
|
|
||||||
|| commit.author?.username
|
|
||||||
|| commit.author?.full_name
|
|
||||||
|| commit.author?.name
|
|
||||||
|| commit.commit?.author?.name
|
|
||||||
|| commit.committer?.login
|
|
||||||
|| commit.committer?.username
|
|
||||||
|| commit.committer?.full_name
|
|
||||||
|| commit.committer?.name
|
|
||||||
|| '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
|
|
||||||
return commit.author?.avatar_url
|
|
||||||
|| commit.author?.avatarUrl
|
|
||||||
|| commit.committer?.avatar_url
|
|
||||||
|| commit.committer?.avatarUrl
|
|
||||||
|| '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = commitAuthorName(commit);
|
|
||||||
const avatarUrl = commitAuthorAvatarUrl(commit);
|
|
||||||
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
{avatarUrl && !failed ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate font-medium">{name}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+17
-110
@@ -6,8 +6,6 @@ export type DataTableColumn<T> = {
|
|||||||
key: string;
|
key: string;
|
||||||
header: string;
|
header: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
width?: number;
|
|
||||||
flexible?: boolean;
|
|
||||||
cell: (row: T) => ReactNode;
|
cell: (row: T) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,25 +16,6 @@ export type DataTableFilter<T> = {
|
|||||||
getValue: (row: T) => string;
|
getValue: (row: T) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
|
|
||||||
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
|
|
||||||
|
|
||||||
function getColumnWidth<T>(column: DataTableColumn<T>) {
|
|
||||||
if (column.key === 'no') return 75;
|
|
||||||
if (column.width) return column.width;
|
|
||||||
|
|
||||||
const widthMatch = column.className?.match(spacingWidthPattern);
|
|
||||||
if (widthMatch?.[1]) return Number(widthMatch[1]);
|
|
||||||
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
|
|
||||||
if (column.key === 'actions') return 80;
|
|
||||||
|
|
||||||
return 160;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripWidthClass(className?: string) {
|
|
||||||
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableCard<T>({
|
export function DataTableCard<T>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -49,8 +28,7 @@ export function DataTableCard<T>({
|
|||||||
pageSize = 8,
|
pageSize = 8,
|
||||||
minWidth = 980,
|
minWidth = 980,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyMessage = '데이터가 없습니다.',
|
emptyMessage = '데이터가 없습니다.'
|
||||||
serverPagination
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -64,23 +42,12 @@ export function DataTableCard<T>({
|
|||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
serverPagination?: {
|
|
||||||
page: number;
|
|
||||||
total: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onSearchChange?: (keyword: string) => void;
|
|
||||||
filterValues?: Record<string, string>;
|
|
||||||
onFilterChange?: (key: string, value: string) => void;
|
|
||||||
};
|
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const serverMode = Boolean(serverPagination);
|
|
||||||
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
|
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
if (serverMode) return rows;
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
@@ -88,40 +55,23 @@ export function DataTableCard<T>({
|
|||||||
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
||||||
: true;
|
: true;
|
||||||
const matchesFilters = filters.every((filter) => {
|
const matchesFilters = filters.every((filter) => {
|
||||||
const selected = activeFilterValues[filter.key];
|
const selected = filterValues[filter.key];
|
||||||
return !selected || filter.getValue(row) === selected;
|
return !selected || filter.getValue(row) === selected;
|
||||||
});
|
});
|
||||||
|
|
||||||
return matchesSearch && matchesFilters;
|
return matchesSearch && matchesFilters;
|
||||||
});
|
});
|
||||||
}, [activeFilterValues, filters, getSearchText, rows, search, serverMode]);
|
}, [filterValues, filters, getSearchText, rows, search]);
|
||||||
|
|
||||||
const totalCount = serverPagination?.total ?? filteredRows.length;
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
const currentPage = Math.min(page, totalPages);
|
||||||
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
||||||
const configuredFlexibleColumnIndex = columns.findIndex((column) => column.flexible);
|
|
||||||
const flexibleColumnIndex = configuredFlexibleColumnIndex >= 0
|
|
||||||
? configuredFlexibleColumnIndex
|
|
||||||
: columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
|
||||||
|
|
||||||
const updateFilter = (key: string, value: string) => {
|
const updateFilter = (key: string, value: string) => {
|
||||||
if (serverPagination?.onFilterChange) {
|
|
||||||
serverPagination.onFilterChange(key, value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const movePage = (nextPage: number) => {
|
|
||||||
if (serverPagination) {
|
|
||||||
serverPagination.onPageChange(nextPage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPage(nextPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
@@ -137,9 +87,7 @@ export function DataTableCard<T>({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value;
|
setSearch(event.target.value);
|
||||||
setSearch(value);
|
|
||||||
serverPagination?.onSearchChange?.(value);
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -151,7 +99,7 @@ export function DataTableCard<T>({
|
|||||||
{filter.label}
|
{filter.label}
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={activeFilterValues[filter.key] ?? ''}
|
value={filterValues[filter.key] ?? ''}
|
||||||
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@@ -168,36 +116,11 @@ export function DataTableCard<T>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||||
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}>
|
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
||||||
<colgroup>
|
|
||||||
{columns.map((column, columnIndex) => (
|
|
||||||
<col
|
|
||||||
key={column.key}
|
|
||||||
style={
|
|
||||||
columnIndex === flexibleColumnIndex
|
|
||||||
? { width: '100%' }
|
|
||||||
: { width: getColumnWidth(column) }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</colgroup>
|
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column, columnIndex) => (
|
{columns.map((column) => (
|
||||||
<th
|
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
||||||
key={column.key}
|
|
||||||
className={
|
|
||||||
column.key === 'no'
|
|
||||||
? 'py-3 text-center font-medium'
|
|
||||||
: columnIndex === flexibleColumnIndex
|
|
||||||
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
|
|
||||||
: column.className ?? 'px-5 py-3 font-medium'
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
|
|
||||||
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{column.header}
|
{column.header}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -206,25 +129,9 @@ export function DataTableCard<T>({
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{!loading && pageRows.map((row) => (
|
{!loading && pageRows.map((row) => (
|
||||||
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
||||||
{columns.map((column, columnIndex) => (
|
{columns.map((column) => (
|
||||||
<td
|
<td key={column.key} className="px-5 py-3">
|
||||||
key={column.key}
|
{column.cell(row)}
|
||||||
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
|
|
||||||
style={
|
|
||||||
column.key === 'no'
|
|
||||||
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
|
|
||||||
: columnIndex === flexibleColumnIndex
|
|
||||||
? { width: '100%' }
|
|
||||||
: { width: getColumnWidth(column) }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.key === 'no' ? (
|
|
||||||
<span className="block w-full text-center font-semibold">
|
|
||||||
{String((row as { no?: unknown }).no ?? '')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
column.cell(row)
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -241,14 +148,14 @@ export function DataTableCard<T>({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => movePage(Math.max(1, currentPage - 1))}
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
이전
|
이전
|
||||||
@@ -257,7 +164,7 @@ export function DataTableCard<T>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => movePage(Math.min(totalPages, currentPage + 1))}
|
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export type RequesterLike = {
|
|
||||||
requestedBy?: string | null;
|
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
createdBy?: string | null;
|
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function requesterName(item: RequesterLike) {
|
|
||||||
return item.requestedByName
|
|
||||||
|| item.createdByName
|
|
||||||
|| item.requestedBy
|
|
||||||
|| item.createdBy
|
|
||||||
|| item.requestedByEmail
|
|
||||||
|| item.createdByEmail
|
|
||||||
|| '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = requesterName(item);
|
|
||||||
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle text-muted-foreground">
|
|
||||||
<span className="leading-none">{label}</span>
|
|
||||||
{name === '-' ? null : avatarUrl && !failed ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<b className="truncate leading-none text-foreground">{name}</b>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Boxes,
|
||||||
|
ChartNoAxesColumnIncreasing,
|
||||||
|
FileKey,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
Megaphone,
|
||||||
|
Network,
|
||||||
|
Rocket,
|
||||||
|
ServerCog,
|
||||||
|
ShieldCheck,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const navGroups = [
|
||||||
|
{
|
||||||
|
label: '플랫폼',
|
||||||
|
items: [
|
||||||
|
{ label: '대시보드', icon: LayoutDashboard, active: true },
|
||||||
|
{
|
||||||
|
label: '공통앱',
|
||||||
|
icon: ServerCog,
|
||||||
|
children: [
|
||||||
|
{ label: '소스 관리', view: 'commonSources' },
|
||||||
|
{ label: '빌드 관리', view: 'commonBuilds' },
|
||||||
|
{ label: '구성요소', view: 'commonComponents' },
|
||||||
|
{ label: '로그/이벤트', view: 'commonLogs' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '런처',
|
||||||
|
icon: Rocket,
|
||||||
|
children: [
|
||||||
|
{ label: '소스 관리', view: 'launcherSources' },
|
||||||
|
{ label: '빌드 관리', view: 'launcherBuilds' },
|
||||||
|
{ label: '버전 관리' },
|
||||||
|
{ label: '구성요소', view: 'launcherComponents' },
|
||||||
|
{ label: '로그/이벤트', view: 'launcherLogs' },
|
||||||
|
{ label: '앱 연결' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '어플리케이션',
|
||||||
|
icon: Boxes,
|
||||||
|
children: [
|
||||||
|
{ label: '앱 관리' },
|
||||||
|
{ label: '저장소 관리' },
|
||||||
|
{ label: '소스 관리' },
|
||||||
|
{ label: '빌드 관리' },
|
||||||
|
{ label: '릴리즈 관리' },
|
||||||
|
{ label: '실행 설정' },
|
||||||
|
{ label: '배포 관리' },
|
||||||
|
{ label: '마이그레이션' },
|
||||||
|
{ label: '로그/이벤트' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '테일스케일',
|
||||||
|
icon: Network,
|
||||||
|
children: [
|
||||||
|
{ label: '노드 현황' },
|
||||||
|
{ label: '라우트' },
|
||||||
|
{ label: 'ACL' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Agent',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
children: [
|
||||||
|
{ label: '장비 등록' },
|
||||||
|
{ label: '장비 제어' },
|
||||||
|
{ label: '설치 상태' },
|
||||||
|
{ label: 'SSH 키 배포' },
|
||||||
|
{ label: 'Workspace 설정' },
|
||||||
|
{ label: '원격 명령' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '플랫폼 게이트웨이',
|
||||||
|
icon: ServerCog,
|
||||||
|
children: [
|
||||||
|
{ label: '연결 상태' },
|
||||||
|
{ label: 'Agent 관리' },
|
||||||
|
{ label: '장비 동기화' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '운영',
|
||||||
|
items: [
|
||||||
|
{ label: '관리자 관리', icon: ShieldCheck },
|
||||||
|
{ label: '조직 관리', icon: Boxes },
|
||||||
|
{ label: '사용자/세션', icon: Users },
|
||||||
|
{ label: '라이센스', icon: FileKey },
|
||||||
|
{ label: '공지사항', icon: Megaphone }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '시스템',
|
||||||
|
items: [
|
||||||
|
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
||||||
|
{ label: '정책', icon: ShieldCheck },
|
||||||
|
{ label: '인프라', icon: ServerCog }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const metrics = [
|
||||||
|
{ label: '활성 앱', value: '10', delta: '+0', hint: '런처 등록 앱 10개', tone: 'emerald' },
|
||||||
|
{ label: '접속 사용자', value: '1,284', delta: '+9.8%', hint: '활성 세션 176개', tone: 'sky' },
|
||||||
|
{ label: '라이센스 사용률', value: '72%', delta: '+4%', hint: '468 / 650 시트', tone: 'amber' },
|
||||||
|
{ label: '런타임 정상률', value: '96.4%', delta: '-1.2%', hint: '주의 워크로드 3개', tone: 'rose' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const launcherVersions = [
|
||||||
|
{
|
||||||
|
name: 'Autonomy Studio Launcher',
|
||||||
|
appKey: 'launcher',
|
||||||
|
latestVersion: '1.0.1',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
status: '활성',
|
||||||
|
artifact: 'autonomy-studio-launcher-1.0.1.deb',
|
||||||
|
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
|
||||||
|
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const authApiEndpoints = [
|
||||||
|
{ group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' },
|
||||||
|
{ group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' },
|
||||||
|
{ group: '인증', method: 'POST', path: '/auth/refresh', purpose: 'refresh token 회전 및 access token 재발급', auth: '쿠키/토큰' },
|
||||||
|
{ group: '인증', method: 'POST', path: '/auth/logout', purpose: 'refresh token 폐기 및 쿠키 제거', auth: '쿠키/토큰' },
|
||||||
|
{ group: '사용자', method: 'GET', path: '/auth/me', purpose: '현재 사용자, 앱 권한, 라이센스, 소속 조회', auth: 'Bearer' },
|
||||||
|
{ group: '앱 연결', method: 'POST', path: '/auth/handoff', purpose: '런처에서 앱으로 넘길 handoff token 생성', auth: 'Bearer' },
|
||||||
|
{ group: '앱 연결', method: 'POST', path: '/auth/handoff/exchange', purpose: 'handoff token을 앱용 token pair로 교환', auth: '없음' },
|
||||||
|
{ group: '관리자', method: 'POST', path: '/auth/admin/users', purpose: '관리자 사용자 생성, 소속, 라이센스 할당', auth: 'ADMIN' },
|
||||||
|
{ group: '관리자', method: 'GET', path: '/auth/admin/organizations', purpose: '조직 목록 조회', auth: 'ADMIN' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const apps = [
|
||||||
|
{
|
||||||
|
name: 'ADS IDE',
|
||||||
|
key: 'ADS IDE',
|
||||||
|
status: '활성',
|
||||||
|
version: '1.1.0',
|
||||||
|
containers: 2,
|
||||||
|
cpu: 52,
|
||||||
|
memory: 68,
|
||||||
|
gitea: './apps/ads-ide/ads-ide-fe',
|
||||||
|
release: 'ADS-IDE-1.1.0.AppImage',
|
||||||
|
secrets: 8,
|
||||||
|
launcherLinked: true,
|
||||||
|
launcherOrder: 1,
|
||||||
|
launchUrl: 'Ingress 예정 / localhost:5175'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LMS',
|
||||||
|
key: 'LMS',
|
||||||
|
status: '활성',
|
||||||
|
version: '1.0.0',
|
||||||
|
containers: 3,
|
||||||
|
cpu: 74,
|
||||||
|
memory: 81,
|
||||||
|
gitea: './apps/lms/lms-fe',
|
||||||
|
release: 'LMS-1.0.0.AppImage',
|
||||||
|
secrets: 11,
|
||||||
|
launcherLinked: true,
|
||||||
|
launcherOrder: 2,
|
||||||
|
launchUrl: 'Ingress 예정 / localhost:5176'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Data Management',
|
||||||
|
key: 'Data Management',
|
||||||
|
status: '준비중',
|
||||||
|
version: '1.0.0',
|
||||||
|
containers: 0,
|
||||||
|
cpu: 31,
|
||||||
|
memory: 48,
|
||||||
|
gitea: '미연결',
|
||||||
|
release: 'data-management',
|
||||||
|
secrets: 0,
|
||||||
|
launcherLinked: false,
|
||||||
|
launcherOrder: null,
|
||||||
|
launchUrl: '미설정'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FMS',
|
||||||
|
key: 'FMS',
|
||||||
|
status: '활성',
|
||||||
|
version: '2.3.1',
|
||||||
|
containers: 0,
|
||||||
|
cpu: 63,
|
||||||
|
memory: 57,
|
||||||
|
gitea: '미연결',
|
||||||
|
release: 'fms',
|
||||||
|
secrets: 0,
|
||||||
|
launcherLinked: true,
|
||||||
|
launcherOrder: 3,
|
||||||
|
launchUrl: '미설정'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ADS Education',
|
||||||
|
key: 'ADS Education',
|
||||||
|
status: '활성',
|
||||||
|
version: '1.0.0',
|
||||||
|
containers: 0,
|
||||||
|
cpu: 18,
|
||||||
|
memory: 22,
|
||||||
|
gitea: '미연결',
|
||||||
|
release: 'ads-education',
|
||||||
|
secrets: 0,
|
||||||
|
launcherLinked: true,
|
||||||
|
launcherOrder: 4,
|
||||||
|
launchUrl: '미설정'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Calibration',
|
||||||
|
key: 'Calibration',
|
||||||
|
status: '활성',
|
||||||
|
version: '1.0.4',
|
||||||
|
containers: 0,
|
||||||
|
cpu: 21,
|
||||||
|
memory: 26,
|
||||||
|
gitea: '미연결',
|
||||||
|
release: 'calibration',
|
||||||
|
secrets: 0,
|
||||||
|
launcherLinked: false,
|
||||||
|
launcherOrder: null,
|
||||||
|
launchUrl: '미설정'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HDMAP Builder',
|
||||||
|
key: 'HDMAP Builder',
|
||||||
|
status: '활성',
|
||||||
|
version: '1.0.0',
|
||||||
|
containers: 0,
|
||||||
|
cpu: 15,
|
||||||
|
memory: 19,
|
||||||
|
gitea: '미연결',
|
||||||
|
release: 'hdmap-builder',
|
||||||
|
secrets: 0,
|
||||||
|
launcherLinked: false,
|
||||||
|
launcherOrder: null,
|
||||||
|
launchUrl: '미설정'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sessions = [
|
||||||
|
{ user: 'kim.admin', role: '소유자', location: '서울', device: 'Chrome macOS', state: '활성', lastSeen: '2분 전' },
|
||||||
|
{ user: 'park.ops', role: '운영자', location: '판교', device: 'Edge Windows', state: '활성', lastSeen: '7분 전' },
|
||||||
|
{ user: 'lee.viewer', role: '조회자', location: '부산', device: 'Safari iOS', state: '대기', lastSeen: '31분 전' },
|
||||||
|
{ user: 'choi.dev', role: '개발자', location: '서울', device: 'Firefox Linux', state: '잠김', lastSeen: '1시간 전' },
|
||||||
|
{ user: 'han.pm', role: '관리자', location: '대전', device: 'Chrome Windows', state: '활성', lastSeen: '12분 전' },
|
||||||
|
{ user: 'jung.viewer', role: '조회자', location: '광주', device: 'Safari macOS', state: '대기', lastSeen: '46분 전' },
|
||||||
|
{ user: 'yoon.ops', role: '운영자', location: '서울', device: 'Edge Windows', state: '활성', lastSeen: '5분 전' },
|
||||||
|
{ user: 'seo.admin', role: '관리자', location: '판교', device: 'Chrome Linux', state: '활성', lastSeen: '1분 전' },
|
||||||
|
{ user: 'moon.dev', role: '개발자', location: '부산', device: 'Firefox Linux', state: '잠김', lastSeen: '2시간 전' },
|
||||||
|
{ user: 'lim.user', role: '사용자', location: '인천', device: 'Chrome Android', state: '대기', lastSeen: '28분 전' },
|
||||||
|
{ user: 'oh.instructor', role: '강사', location: '서울', device: 'Safari iPadOS', state: '활성', lastSeen: '9분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const licensePlans = [
|
||||||
|
{
|
||||||
|
code: 'FREE',
|
||||||
|
name: 'Free',
|
||||||
|
description: '기본 무료 라이선스',
|
||||||
|
defaultDurationDays: 365,
|
||||||
|
assigned: 182,
|
||||||
|
activeUsers: 164,
|
||||||
|
expiringSoon: 18,
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EDU',
|
||||||
|
name: 'Education',
|
||||||
|
description: '교육기관용 라이선스',
|
||||||
|
defaultDurationDays: 365,
|
||||||
|
assigned: 296,
|
||||||
|
activeUsers: 271,
|
||||||
|
expiringSoon: 21,
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'PRO',
|
||||||
|
name: 'Professional',
|
||||||
|
description: '전문가용 라이선스',
|
||||||
|
defaultDurationDays: 365,
|
||||||
|
assigned: 172,
|
||||||
|
activeUsers: 158,
|
||||||
|
expiringSoon: 3,
|
||||||
|
status: '활성'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const userLicenseAssignments = [
|
||||||
|
{ user: 'admin@futuredrive.net', name: 'ATNMY Studio Admin', code: 'PRO', licenseName: 'Professional', startsAt: '06.01.', expiresAt: '무기한', assignedBy: 'system', status: '활성' },
|
||||||
|
{ user: 'teacher@futuredrive.net', name: 'Future Drive Teacher', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||||
|
{ user: 'teacher@yu.ac.kr', name: 'YU Instructor', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||||
|
{ user: 'student01@example.com', name: 'student01', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
||||||
|
{ user: 'student03@example.com', name: 'student03', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
||||||
|
{ user: 'ops@autonomystudio.local', name: 'ops', code: 'PRO', licenseName: 'Professional', startsAt: '06.10.', expiresAt: '06.10.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||||
|
{ user: 'viewer@example.com', name: 'viewer', code: 'FREE', licenseName: 'Free', startsAt: '07.01.', expiresAt: '07.01.', assignedBy: 'system', status: '주의' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appBundles = [
|
||||||
|
{
|
||||||
|
name: 'LMS',
|
||||||
|
key: 'LMS',
|
||||||
|
status: '활성',
|
||||||
|
namespace: 'stdo-lms',
|
||||||
|
launcherTarget: 'lms-fe',
|
||||||
|
launcherArtifact: 'LMS-1.0.0.AppImage',
|
||||||
|
ingress: 'https://lms.apps.autonomystudio.local',
|
||||||
|
release: 'lms-1.0.0',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'lms-fe',
|
||||||
|
role: '런처 다운로드 앱',
|
||||||
|
type: 'electron',
|
||||||
|
workload: 'artifact',
|
||||||
|
image: 'LMS-1.0.0.AppImage',
|
||||||
|
replicas: '-',
|
||||||
|
status: '배포됨',
|
||||||
|
repository: './apps/lms/lms-fe'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lms-be',
|
||||||
|
role: 'API 서버',
|
||||||
|
type: 'deployment',
|
||||||
|
workload: 'Deployment/lms-api',
|
||||||
|
image: 'registry.local/lms-be:1.0.0',
|
||||||
|
replicas: '2/2',
|
||||||
|
status: '실행 중',
|
||||||
|
repository: './apps/lms/lms-be'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lms-adm-fe',
|
||||||
|
role: '강사용 웹 어드민',
|
||||||
|
type: 'deployment',
|
||||||
|
workload: 'Deployment/lms-adm-web',
|
||||||
|
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||||
|
replicas: '1/1',
|
||||||
|
status: '실행 중',
|
||||||
|
repository: './apps/lms/lms-adm-fe'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lms-db',
|
||||||
|
role: 'PostgreSQL',
|
||||||
|
type: 'statefulset',
|
||||||
|
workload: 'StatefulSet/lms-postgres',
|
||||||
|
image: 'postgres:16',
|
||||||
|
replicas: '1/1',
|
||||||
|
status: '실행 중',
|
||||||
|
repository: 'PVC/lms-postgres-data'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ADS IDE',
|
||||||
|
key: 'ADS IDE',
|
||||||
|
status: '활성',
|
||||||
|
namespace: 'stdo-ads-ide',
|
||||||
|
launcherTarget: 'ads-ide-fe',
|
||||||
|
launcherArtifact: 'ADS-IDE-1.1.0.AppImage',
|
||||||
|
ingress: 'https://ads-ide.apps.autonomystudio.local',
|
||||||
|
release: 'ads-ide-1.1.0',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'ads-ide-fe',
|
||||||
|
role: '런처 다운로드 앱',
|
||||||
|
type: 'electron',
|
||||||
|
workload: 'artifact',
|
||||||
|
image: 'ADS-IDE-1.1.0.AppImage',
|
||||||
|
replicas: '-',
|
||||||
|
status: '배포됨',
|
||||||
|
repository: './apps/ads-ide/ads-ide-fe'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ads-ide-be',
|
||||||
|
role: 'API 서버',
|
||||||
|
type: 'deployment',
|
||||||
|
workload: 'Deployment/ads-ide-api',
|
||||||
|
image: 'registry.local/ads-ide-be:0.1.0',
|
||||||
|
replicas: '1/1',
|
||||||
|
status: '주의',
|
||||||
|
repository: './apps/ads-ide/ads-ide-be'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Data Management',
|
||||||
|
key: 'Data Management',
|
||||||
|
status: '준비중',
|
||||||
|
namespace: 'stdo-data',
|
||||||
|
launcherTarget: 'data-management-fe',
|
||||||
|
launcherArtifact: '미등록',
|
||||||
|
ingress: '미설정',
|
||||||
|
release: 'data-management-1.0.0',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'data-management-fe',
|
||||||
|
role: '런처 다운로드 앱',
|
||||||
|
type: 'electron',
|
||||||
|
workload: 'artifact',
|
||||||
|
image: '미등록',
|
||||||
|
replicas: '-',
|
||||||
|
status: '준비중',
|
||||||
|
repository: '미연결'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'data-api',
|
||||||
|
role: 'API 서버',
|
||||||
|
type: 'deployment',
|
||||||
|
workload: 'Deployment/data-api',
|
||||||
|
image: 'registry.local/data-api:1.0.0',
|
||||||
|
replicas: '0/1',
|
||||||
|
status: '준비중',
|
||||||
|
repository: '미연결'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appSourceConfigs = [
|
||||||
|
{
|
||||||
|
app: 'LMS',
|
||||||
|
component: 'lms-fe',
|
||||||
|
repository: 'platform/lms-fe',
|
||||||
|
branch: 'release',
|
||||||
|
commit: '8d41c2a',
|
||||||
|
sourceType: 'electron',
|
||||||
|
target: 'LMS-1.0.0.AppImage',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'LMS',
|
||||||
|
component: 'lms-be',
|
||||||
|
repository: 'platform/lms-be',
|
||||||
|
branch: 'release',
|
||||||
|
commit: '3ac92ef',
|
||||||
|
sourceType: 'container',
|
||||||
|
target: 'registry.local/lms-be:1.0.0',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'LMS',
|
||||||
|
component: 'lms-adm-fe',
|
||||||
|
repository: 'platform/lms-adm-fe',
|
||||||
|
branch: 'release',
|
||||||
|
commit: 'f17ab31',
|
||||||
|
sourceType: 'static-web',
|
||||||
|
target: 'registry.local/lms-adm-fe:0.1.0',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'LMS',
|
||||||
|
component: 'lms-db',
|
||||||
|
repository: 'platform/lms-be',
|
||||||
|
branch: 'release',
|
||||||
|
commit: '3ac92ef',
|
||||||
|
sourceType: 'migration',
|
||||||
|
target: 'prisma/migrations',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'ADS IDE',
|
||||||
|
component: 'ads-ide-fe',
|
||||||
|
repository: 'platform/ads-ide-fe',
|
||||||
|
branch: 'develop',
|
||||||
|
commit: 'b902a11',
|
||||||
|
sourceType: 'electron',
|
||||||
|
target: 'ADS-IDE-1.1.0.AppImage',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'ADS IDE',
|
||||||
|
component: 'ads-ide-be',
|
||||||
|
repository: 'platform/ads-ide-be',
|
||||||
|
branch: 'develop',
|
||||||
|
commit: '10fd8b0',
|
||||||
|
sourceType: 'container',
|
||||||
|
target: 'registry.local/ads-ide-be:0.1.0',
|
||||||
|
status: '주의'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appBuildJobs = [
|
||||||
|
{ app: 'LMS', component: 'lms-fe', branch: 'release', job: 'electron build', artifact: 'LMS-1.0.0.AppImage', duration: '6분 12초', status: '완료', time: '12분 전' },
|
||||||
|
{ app: 'LMS', component: 'lms-be', branch: 'release', job: 'image build', artifact: 'registry.local/lms-be:1.0.0', duration: '4분 31초', status: '완료', time: '10분 전' },
|
||||||
|
{ app: 'LMS', component: 'lms-adm-fe', branch: 'release', job: 'web image build', artifact: 'registry.local/lms-adm-fe:0.1.0', duration: '3분 18초', status: '완료', time: '9분 전' },
|
||||||
|
{ app: 'ADS IDE', component: 'ads-ide-be', branch: 'develop', job: 'image build', artifact: 'registry.local/ads-ide-be:0.1.0', duration: '진행 중', status: '실행 중', time: '3분 전' },
|
||||||
|
{ app: 'FMS', component: 'fms-api', branch: 'develop', job: 'image build', artifact: '미생성', duration: '1분 44초', status: '주의', time: '24분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appReleasePipelines = [
|
||||||
|
{ app: 'LMS', version: '1.0.0', sourceBranch: 'release', releaseBranch: 'main', commit: '3ac92ef', artifacts: 'electron 1, images 2, migrations 4', status: '배포됨', createdAt: '06.18.' },
|
||||||
|
{ app: 'ADS IDE', version: '1.1.0', sourceBranch: 'develop', releaseBranch: 'release', commit: 'b902a11', artifacts: 'electron 1, images 1', status: '카나리', createdAt: '06.16.' },
|
||||||
|
{ app: 'FMS', version: '2.3.1', sourceBranch: 'develop', releaseBranch: 'release', commit: 'c82f09d', artifacts: 'images 1', status: '초안', createdAt: '06.14.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appDeploymentJobs = [
|
||||||
|
{ app: 'LMS', environment: '운영', release: '1.0.0', strategy: 'rolling update', target: 'stdo-lms', status: '완료', requestedBy: 'kim.admin', time: '8분 전' },
|
||||||
|
{ app: 'ADS IDE', environment: '개발', release: '1.1.0-canary', strategy: 'redeploy', target: 'stdo-ads-ide', status: '실행 중', requestedBy: 'park.ops', time: '2분 전' },
|
||||||
|
{ app: 'FMS', environment: '개발', release: '2.3.1-dev', strategy: 'deploy', target: 'stdo-fms', status: '주의', requestedBy: 'choi.dev', time: '27분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const appMigrationJobs = [
|
||||||
|
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260618091000_add_course_indexes', status: '완료', time: '7분 전' },
|
||||||
|
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260617083000_add_assignment_status', status: '완료', time: '1일 전' },
|
||||||
|
{ app: 'ADS IDE', component: 'ads-ide-db', branch: 'develop', path: 'migrations', version: '20260616070000_workspace_schema', status: '대기', time: '대기 중' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const containers = [
|
||||||
|
{ name: 'launcher-api', app: 'Autonomy Studio Launcher', image: 'atnmy-stdo-v2-be:local', cpu: 43, memory: 62, status: '실행 중' },
|
||||||
|
{ name: 'launcher-postgres', app: 'Autonomy Studio Launcher', image: 'postgres:launcher', cpu: 24, memory: 39, status: '실행 중' },
|
||||||
|
{ name: 'lms-api', app: 'LMS', image: 'lms-be:local', cpu: 67, memory: 59, status: '실행 중' },
|
||||||
|
{ name: 'ads-ide-api', app: 'ADS IDE', image: 'ads-ide-be:local', cpu: 89, memory: 84, status: '주의' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const k3sWorkloads = [
|
||||||
|
{
|
||||||
|
namespace: 'stdo-launcher',
|
||||||
|
app: 'Autonomy Studio Launcher',
|
||||||
|
workload: 'Deployment/launcher-api',
|
||||||
|
pod: 'launcher-api-7c9d85f9b7-q2x4m',
|
||||||
|
image: 'registry.local/atnmy-stdo-v2-be:1.0.1',
|
||||||
|
replicas: '2/2',
|
||||||
|
node: 'cluster-master-01',
|
||||||
|
cpu: 43,
|
||||||
|
memory: 62,
|
||||||
|
restarts: 0,
|
||||||
|
age: '3d',
|
||||||
|
status: '실행 중'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: 'stdo-launcher',
|
||||||
|
app: 'Autonomy Studio Launcher',
|
||||||
|
workload: 'StatefulSet/launcher-postgres',
|
||||||
|
pod: 'launcher-postgres-0',
|
||||||
|
image: 'postgres:16',
|
||||||
|
replicas: '1/1',
|
||||||
|
node: 'cluster-master-01',
|
||||||
|
cpu: 24,
|
||||||
|
memory: 39,
|
||||||
|
restarts: 0,
|
||||||
|
age: '3d',
|
||||||
|
status: '실행 중'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: 'stdo-lms',
|
||||||
|
app: 'LMS',
|
||||||
|
workload: 'Deployment/lms-api',
|
||||||
|
pod: 'lms-api-5f6db8d675-jt2xk',
|
||||||
|
image: 'registry.local/lms-be:1.0.0',
|
||||||
|
replicas: '2/2',
|
||||||
|
node: 'cluster-worker-01',
|
||||||
|
cpu: 67,
|
||||||
|
memory: 59,
|
||||||
|
restarts: 1,
|
||||||
|
age: '18h',
|
||||||
|
status: '실행 중'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: 'stdo-lms',
|
||||||
|
app: 'LMS',
|
||||||
|
workload: 'Deployment/lms-adm-web',
|
||||||
|
pod: 'lms-adm-web-778b4d4f9b-bxz92',
|
||||||
|
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||||
|
replicas: '1/1',
|
||||||
|
node: 'cluster-worker-02',
|
||||||
|
cpu: 31,
|
||||||
|
memory: 44,
|
||||||
|
restarts: 0,
|
||||||
|
age: '18h',
|
||||||
|
status: '실행 중'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: 'stdo-lms',
|
||||||
|
app: 'LMS',
|
||||||
|
workload: 'StatefulSet/lms-postgres',
|
||||||
|
pod: 'lms-postgres-0',
|
||||||
|
image: 'postgres:16',
|
||||||
|
replicas: '1/1',
|
||||||
|
node: 'cluster-worker-01',
|
||||||
|
cpu: 18,
|
||||||
|
memory: 71,
|
||||||
|
restarts: 0,
|
||||||
|
age: '18h',
|
||||||
|
status: '실행 중'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: 'stdo-ads-ide',
|
||||||
|
app: 'ADS IDE',
|
||||||
|
workload: 'Deployment/ads-ide-api',
|
||||||
|
pod: 'ads-ide-api-84b8787d7d-r8qkp',
|
||||||
|
image: 'registry.local/ads-ide-be:0.1.0',
|
||||||
|
replicas: '1/1',
|
||||||
|
node: 'cluster-worker-02',
|
||||||
|
cpu: 89,
|
||||||
|
memory: 84,
|
||||||
|
restarts: 4,
|
||||||
|
age: '7h',
|
||||||
|
status: '주의'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const containerBlueprints = [
|
||||||
|
{
|
||||||
|
app: 'LMS',
|
||||||
|
namespace: 'stdo-lms',
|
||||||
|
release: 'lms-1.0.0',
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
name: 'lms-api',
|
||||||
|
kind: 'Deployment',
|
||||||
|
image: 'registry.local/lms-be:1.0.0',
|
||||||
|
replicas: 2,
|
||||||
|
ports: '4000/TCP',
|
||||||
|
env: 14,
|
||||||
|
secrets: 5,
|
||||||
|
storage: '-',
|
||||||
|
ingress: 'api.lms.apps.local',
|
||||||
|
status: '구성완료'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lms-adm-web',
|
||||||
|
kind: 'Deployment',
|
||||||
|
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||||
|
replicas: 1,
|
||||||
|
ports: '80/TCP',
|
||||||
|
env: 6,
|
||||||
|
secrets: 2,
|
||||||
|
storage: '-',
|
||||||
|
ingress: 'admin.lms.apps.local',
|
||||||
|
status: '구성완료'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lms-postgres',
|
||||||
|
kind: 'StatefulSet',
|
||||||
|
image: 'postgres:16',
|
||||||
|
replicas: 1,
|
||||||
|
ports: '5432/TCP',
|
||||||
|
env: 8,
|
||||||
|
secrets: 3,
|
||||||
|
storage: '20Gi / RWO',
|
||||||
|
ingress: '-',
|
||||||
|
status: '구성완료'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'ADS IDE',
|
||||||
|
namespace: 'stdo-ads-ide',
|
||||||
|
release: 'ads-ide-1.1.0',
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
name: 'ads-ide-api',
|
||||||
|
kind: 'Deployment',
|
||||||
|
image: 'registry.local/ads-ide-be:0.1.0',
|
||||||
|
replicas: 1,
|
||||||
|
ports: '4300/TCP',
|
||||||
|
env: 9,
|
||||||
|
secrets: 4,
|
||||||
|
storage: '-',
|
||||||
|
ingress: 'api.ads-ide.apps.local',
|
||||||
|
status: '검토필요'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tailscaleNodes = [
|
||||||
|
{
|
||||||
|
name: 'cluster-master-01',
|
||||||
|
ip: '100.64.0.10',
|
||||||
|
role: 'control-plane',
|
||||||
|
user: 'ops@autonomystudio.local',
|
||||||
|
routes: '10.42.0.0/16, 10.43.0.0/16',
|
||||||
|
lastSeen: '1분 전',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cluster-worker-01',
|
||||||
|
ip: '100.64.0.11',
|
||||||
|
role: 'worker',
|
||||||
|
user: 'ops@autonomystudio.local',
|
||||||
|
routes: '-',
|
||||||
|
lastSeen: '3분 전',
|
||||||
|
status: '활성'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gpu-runner-02',
|
||||||
|
ip: '100.64.20.2',
|
||||||
|
role: 'gpu-runner',
|
||||||
|
user: 'mlops@autonomystudio.local',
|
||||||
|
routes: '-',
|
||||||
|
lastSeen: '오프라인',
|
||||||
|
status: '주의'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tailscaleSettings = [
|
||||||
|
{
|
||||||
|
name: 'Tailnet',
|
||||||
|
key: 'TAILSCALE_TAILNET',
|
||||||
|
value: 'autonomy-studio.ts.net',
|
||||||
|
status: '활성',
|
||||||
|
description: '런처와 클러스터 운영 노드가 연결되는 tailnet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Subnet Routes',
|
||||||
|
key: 'TAILSCALE_ADVERTISE_ROUTES',
|
||||||
|
value: '10.42.0.0/16,10.43.0.0/16',
|
||||||
|
status: '활성',
|
||||||
|
description: 'Pod/Service CIDR 접근용 광고 라우트'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Platform Gateway 연동',
|
||||||
|
key: 'TAILSCALE_GATEWAY_PROVIDER',
|
||||||
|
value: 'platform-gateway',
|
||||||
|
status: '활성',
|
||||||
|
description: '런처에는 연결 상태만 제공하고 세부 조회는 플랫폼 게이트웨이가 담당'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const gatewayConnections = [
|
||||||
|
{ name: 'Headscale Server', endpoint: 'https://headscale.autonomystudio.local', role: '노드/ACL 동기화', status: '활성', lastSync: '1분 전' },
|
||||||
|
{ name: 'Autonomy Agent', endpoint: 'agent heartbeat stream', role: 'Agent 등록/상태 집계', status: '활성', lastSync: '30초 전' },
|
||||||
|
{ name: 'Platform Gitea', endpoint: 'https://gitea.autonomystudio.local', role: '런처/앱 소스 버전 저장소', status: '활성', lastSync: '5분 전' },
|
||||||
|
{ name: 'Platform Runtime', endpoint: 'runtime command bus', role: '원격 명령/로그/이벤트', status: '주의', lastSync: '12분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agentDevices = [
|
||||||
|
{ name: 'lab-mkmini-01', account: 'student01@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '2분 전' },
|
||||||
|
{ name: 'lab-f1tenth-03', account: 'student03@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '4분 전' },
|
||||||
|
{ name: 'sim-runner-02', account: 'instructor@example.com', status: '주의', workspace: '/opt/studio/workspaces', sshKey: '대기', lastSeen: '35분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const runtimeCommands = [
|
||||||
|
{ target: 'lab-mkmini-01', command: 'workspace sync', status: '완료', requestedBy: 'park.ops', time: '8분 전' },
|
||||||
|
{ target: 'lab-f1tenth-03', command: 'build module', status: '실행 중', requestedBy: 'kim.admin', time: '3분 전' },
|
||||||
|
{ target: 'sim-runner-02', command: 'collect logs', status: '주의', requestedBy: 'choi.dev', time: '22분 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const notices = [
|
||||||
|
{ scope: '런처', title: 'v1.0.1 업데이트 안내', audience: '전체 사용자', status: '게시됨', date: '04.23.' },
|
||||||
|
{ scope: 'ADS IDE', title: '정기 점검 안내', audience: 'ADS IDE 사용자', status: '게시됨', date: '04.10.' },
|
||||||
|
{ scope: 'LMS', title: '신규 과제 검토 요청', audience: '강사', status: '게시됨', date: '06.02.' },
|
||||||
|
{ scope: '런처', title: '로그인 화면 점검 배너', audience: '전체 사용자', status: '예약됨', date: '06.24.' },
|
||||||
|
{ scope: 'FMS', title: 'FMS 릴리즈 사전 안내', audience: 'FMS 사용자', status: '초안', date: '06.25.' },
|
||||||
|
{ scope: 'ADS Education', title: '교육 콘텐츠 업데이트', audience: 'ADS Education 사용자', status: '게시됨', date: '06.12.' },
|
||||||
|
{ scope: 'Data Management', title: '베타 신청 접수', audience: '운영자', status: '초안', date: '06.18.' },
|
||||||
|
{ scope: '런처', title: '구버전 런처 지원 종료 안내', audience: '전체 사용자', status: '예약됨', date: '07.01.' },
|
||||||
|
{ scope: 'Calibration', title: '캘리브레이션 템플릿 변경', audience: 'Calibration 사용자', status: '게시됨', date: '06.09.' },
|
||||||
|
{ scope: 'HDMAP Builder', title: 'HDMAP Builder 등록 예정', audience: '운영자', status: '초안', date: '06.28.' },
|
||||||
|
{ scope: '런처', title: '파일 서버 점검 공지', audience: '전체 사용자', status: '게시됨', date: '06.15.' },
|
||||||
|
{ scope: 'ADS IDE', title: 'IDE 확장 기능 업데이트', audience: 'ADS IDE 사용자', status: '예약됨', date: '06.30.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const alerts = [
|
||||||
|
{ icon: Bell, label: '확인이 필요한 워크로드 3개' },
|
||||||
|
{ icon: FileKey, label: '14일 내 만료되는 라이센스 42개' },
|
||||||
|
{ icon: KeyRound, label: '이번 주 교체된 시크릿 5개' }
|
||||||
|
];
|
||||||
+17
-31
@@ -1,15 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
FileKey,
|
FileKey,
|
||||||
HardDrive,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
MonitorPlay,
|
|
||||||
MonitorUp,
|
|
||||||
Network,
|
Network,
|
||||||
ScrollText,
|
Rocket,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users
|
Users
|
||||||
@@ -38,19 +34,9 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: '플랫폼',
|
label: '플랫폼',
|
||||||
items: [
|
items: [
|
||||||
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
||||||
{
|
|
||||||
label: '공통앱',
|
|
||||||
icon: ServerCog,
|
|
||||||
children: [
|
|
||||||
{ label: '소스 관리', path: '/common-apps/sources' },
|
|
||||||
{ label: '빌드 관리', path: '/common-apps/builds' },
|
|
||||||
{ label: '구성요소', path: '/common-apps/components' },
|
|
||||||
{ label: '로그/이벤트', path: '/common-apps/logs' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: '런처',
|
label: '런처',
|
||||||
icon: MonitorPlay,
|
icon: Rocket,
|
||||||
children: [
|
children: [
|
||||||
{ label: '소스 관리', path: '/launcher/sources' },
|
{ label: '소스 관리', path: '/launcher/sources' },
|
||||||
{ label: '빌드 관리', path: '/launcher/builds' },
|
{ label: '빌드 관리', path: '/launcher/builds' },
|
||||||
@@ -82,26 +68,28 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ label: '노드 현황', path: '/tailscale/status' },
|
{ label: '노드 현황', path: '/tailscale/status' },
|
||||||
{ label: '라우트', path: '/tailscale/routes' },
|
{ label: '라우트', path: '/tailscale/routes' },
|
||||||
{ label: 'ACL', path: '/tailscale/acl' },
|
{ label: 'ACL', path: '/tailscale/acl' },
|
||||||
|
{ label: '연결 진단', path: '/tailscale/diagnostics' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '플랫폼 관리',
|
label: 'Agent',
|
||||||
icon: MonitorUp,
|
icon: ShieldCheck,
|
||||||
children: [
|
children: [
|
||||||
{ label: '플랫폼 등록', path: '/agents/registration' },
|
{ label: '장비 등록', path: '/agents/registration' },
|
||||||
{ label: '연결 제어', path: '/runtime/devices' },
|
{ label: '장비 제어', path: '/runtime/devices' },
|
||||||
{ label: 'SSH 키', path: '/agents/ssh-keys' },
|
{ label: '설치 상태', path: '/agents/install-status' },
|
||||||
|
{ label: 'SSH 키 배포', path: '/agents/ssh-keys' },
|
||||||
|
{ label: 'Workspace 설정', path: '/agents/workspace' },
|
||||||
{ label: '원격 명령', path: '/agents/commands' }
|
{ label: '원격 명령', path: '/agents/commands' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '인프라',
|
label: '플랫폼 게이트웨이',
|
||||||
icon: HardDrive,
|
icon: ServerCog,
|
||||||
children: [
|
children: [
|
||||||
{ label: '클러스터', path: '/infrastructure/cluster' },
|
{ label: '연결 상태', path: '/gateway/status' },
|
||||||
{ label: '워크로드', path: '/infrastructure/workloads' },
|
{ label: 'Agent 관리', path: '/gateway/agents' },
|
||||||
{ label: '네트워크', path: '/infrastructure/network' },
|
{ label: '장비 동기화', path: '/gateway/devices' }
|
||||||
{ label: '스토리지', path: '/infrastructure/storage' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -111,11 +99,9 @@ export const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||||
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
||||||
{ label: '사용자 관리', path: '/operations/users', icon: Users },
|
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
||||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone },
|
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
||||||
{ label: '약관', path: '/operations/terms', icon: ScrollText },
|
|
||||||
{ label: '매뉴얼', path: '/operations/manuals', icon: BookOpen }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export type CurrentAdminLike = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
avatarUrl?: string | null;
|
|
||||||
appPermissions?: string[];
|
appPermissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+100
-352
@@ -1,31 +1,18 @@
|
|||||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||||
|
import {
|
||||||
|
agentDevices,
|
||||||
|
alerts,
|
||||||
|
gatewayConnections,
|
||||||
|
k3sWorkloads,
|
||||||
|
launcherVersions,
|
||||||
|
runtimeCommands,
|
||||||
|
tailscaleSettings
|
||||||
|
} from '../data/mock';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { PageTitle } from '../layout/AppLayout';
|
import { PageTitle } from '../layout/AppLayout';
|
||||||
import {
|
|
||||||
cancelDeviceAuthorization,
|
|
||||||
createDeviceAuthorization,
|
|
||||||
loadDeviceAuthorizations,
|
|
||||||
loadPlatformDevices,
|
|
||||||
loadPlatformSshKeys,
|
|
||||||
searchPlatformRegistrationUsers,
|
|
||||||
type DeviceAuthorization,
|
|
||||||
type PlatformDevice,
|
|
||||||
type PlatformRegistrationUser,
|
|
||||||
type PlatformSshKey,
|
|
||||||
} from './agents/platform-api';
|
|
||||||
|
|
||||||
const launcherVersions: Array<{
|
|
||||||
name: string; status: string; latestVersion: string; minimumSupportedVersion?: string;
|
|
||||||
artifact: string; downloadUrl: string; releaseNotes: string;
|
|
||||||
}> = [];
|
|
||||||
const tailscaleSettings: Array<{ key: string; name: string; description: string; value: string; status: string }> = [];
|
|
||||||
const gatewayConnections: Array<{ name: string; status: string; role: string; endpoint: string; lastSync: string }> = [];
|
|
||||||
const runtimeCommands: Array<{ target: string; command: string; requestedBy: string; time: string; status: string }> = [];
|
|
||||||
const k3sWorkloads: Array<{ workload: string; namespace: string; pod: string; replicas: string; cpu: number; memory: number; status: string }> = [];
|
|
||||||
const alerts: Array<{ label: string; icon: typeof Settings2 }> = [];
|
|
||||||
|
|
||||||
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||||
|
|
||||||
@@ -331,8 +318,6 @@ export function TailscaleStatusPage() {
|
|||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<TailscaleNode | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
||||||
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
||||||
|
|
||||||
@@ -355,20 +340,6 @@ export function TailscaleStatusPage() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteNode = () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleting(true);
|
|
||||||
setError('');
|
|
||||||
apiRequest<{ deleted: boolean }>(`/tailscale/nodes/${encodeURIComponent(deleteTarget.id)}`, { method: 'DELETE' })
|
|
||||||
.then(() => {
|
|
||||||
setNodes((current) => current.filter((node) => node.id !== deleteTarget.id));
|
|
||||||
setDeleteTarget(null);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((requestError: Error) => setError(requestError.message))
|
|
||||||
.finally(() => setDeleting(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -401,30 +372,13 @@ export function TailscaleStatusPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} />
|
<TailscaleNodeTable nodes={nodes} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
<Modal
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
title="노드 삭제"
|
|
||||||
onClose={() => !deleting && setDeleteTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>취소</Button>
|
|
||||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deleting} onClick={deleteNode}>
|
|
||||||
{deleting ? '삭제 중...' : '삭제'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<strong className="text-foreground">{deleteTarget?.name}</strong> 노드를 Headscale에서 삭제합니다. 연결된 기기의 VPN 연결은 즉시 끊기며, 다시 사용하려면 재등록해야 합니다.
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) {
|
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
||||||
@@ -453,18 +407,17 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
|
|||||||
<th className="px-5 py-3 font-medium">Routes</th>
|
<th className="px-5 py-3 font-medium">Routes</th>
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||||
<th className="px-5 py-3 font-medium">상태</th>
|
<th className="px-5 py-3 font-medium">상태</th>
|
||||||
<th className="px-5 py-3 font-medium"><span className="sr-only">관리</span></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{loading && nodes.length === 0 ? (
|
{loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && nodes.length === 0 ? (
|
{!loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{pageNodes.map((node) => (
|
{pageNodes.map((node) => (
|
||||||
@@ -476,17 +429,6 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
|
|||||||
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
||||||
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
||||||
<td className="px-5 py-3 text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 p-0 text-muted-foreground hover:text-rose-600"
|
|
||||||
title="노드 삭제"
|
|
||||||
aria-label={`${node.name} 노드 삭제`}
|
|
||||||
onClick={() => onDelete(node)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -840,238 +782,81 @@ export function RuntimeDevicesPage() {
|
|||||||
<AgentTable
|
<AgentTable
|
||||||
title="장비 목록"
|
title="장비 목록"
|
||||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||||
|
withCommands
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type NumberedPlatformDevice = PlatformDevice & { no: number };
|
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
||||||
type NumberedDeviceAuthorization = DeviceAuthorization & { no: number };
|
|
||||||
type NumberedPlatformSshKey = PlatformSshKey & { no: number };
|
|
||||||
|
|
||||||
function AgentTable({ title, description }: { title: string; description: string }) {
|
|
||||||
const [devices, setDevices] = useState<PlatformDevice[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
loadPlatformDevices()
|
|
||||||
.then(setDevices)
|
|
||||||
.catch(() => setDevices([]))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const rows = useMemo<NumberedPlatformDevice[]>(
|
|
||||||
() => devices.map((device, index) => ({ ...device, no: devices.length - index })),
|
|
||||||
[devices]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<NumberedPlatformDevice>>>(
|
|
||||||
() => [
|
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
|
||||||
{
|
|
||||||
key: 'platform',
|
|
||||||
header: '플랫폼',
|
|
||||||
flexible: true,
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold">{row.hostname}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.tailscaleHostname || '-'}>{row.tailscaleHostname || '-'}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ key: 'account', header: '계정', width: 210, cell: (row) => <span className="block truncate text-muted-foreground" title={row.accountEmail}>{row.accountEmail}</span> },
|
|
||||||
{ key: 'workspace', header: 'Workspace', width: 280, cell: (row) => <span className="block truncate text-muted-foreground" title={row.workspaceRoot || '-'}>{row.workspaceRoot || '-'}</span> },
|
|
||||||
{ key: 'ssh', header: 'SSH 사용자', width: 130, cell: (row) => row.sshUser || '-' },
|
|
||||||
{ key: 'lastSeen', header: '최근 연결', width: 180, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.lastProvisionedAt)}</span> },
|
|
||||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <StatusBadge status={row.status} /> }
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableCard
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
rows={rows}
|
action={
|
||||||
columns={columns}
|
withCommands ? (
|
||||||
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색"
|
<div className="flex gap-2">
|
||||||
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`}
|
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||||
getRowKey={(row) => row.id}
|
<SlidersHorizontal className="size-4" />
|
||||||
pageSize={6}
|
상태 필터
|
||||||
minWidth={1080}
|
</Button>
|
||||||
loading={loading}
|
<Button className="h-8 px-3 text-[13px]">
|
||||||
emptyMessage="등록된 플랫폼이 없습니다."
|
<RefreshCcw className="size-4" />
|
||||||
|
상태 갱신
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||||
|
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">장비</th>
|
||||||
|
<th className="px-5 py-3 font-medium">계정</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||||
|
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||||
|
<th className="px-5 py-3 font-medium">상태</th>
|
||||||
|
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{agentDevices.map((device) => (
|
||||||
|
<tr className="hover:bg-muted/20" key={device.name}>
|
||||||
|
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||||
|
<td className="px-5 py-3">{device.sshKey}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||||
|
<td className="px-5 py-3"><StatusBadge status={device.status} /></td>
|
||||||
|
{withCommands ? (
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||||
|
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentRegistrationPage() {
|
export function AgentRegistrationPage() {
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [users, setUsers] = useState<PlatformRegistrationUser[]>([]);
|
|
||||||
const [selectedUser, setSelectedUser] = useState<PlatformRegistrationUser | null>(null);
|
|
||||||
const [authorizations, setAuthorizations] = useState<DeviceAuthorization[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [issuing, setIssuing] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [cancelTarget, setCancelTarget] = useState<DeviceAuthorization | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
loadDeviceAuthorizations()
|
|
||||||
.then(setAuthorizations)
|
|
||||||
.catch(() => setAuthorizations([]))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const value = keyword.trim();
|
|
||||||
if (value.length < 2 || selectedUser) {
|
|
||||||
setUsers([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
searchPlatformRegistrationUsers(value).then(setUsers).catch(() => setUsers([]));
|
|
||||||
}, 200);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [keyword, selectedUser]);
|
|
||||||
|
|
||||||
const issue = async () => {
|
|
||||||
if (!selectedUser || issuing) return;
|
|
||||||
setIssuing(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await createDeviceAuthorization(selectedUser.id);
|
|
||||||
await refresh();
|
|
||||||
} catch (issueError) {
|
|
||||||
setError(issueError instanceof Error ? issueError.message : '플랫폼 등록 키를 발급하지 못했습니다.');
|
|
||||||
} finally {
|
|
||||||
setIssuing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = async () => {
|
|
||||||
if (!cancelTarget) return;
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await cancelDeviceAuthorization(cancelTarget.id);
|
|
||||||
setCancelTarget(null);
|
|
||||||
await refresh();
|
|
||||||
} catch (cancelError) {
|
|
||||||
setError(cancelError instanceof Error ? cancelError.message : '플랫폼 등록 키를 취소하지 못했습니다.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = useMemo<NumberedDeviceAuthorization[]>(
|
|
||||||
() => authorizations.map((authorization, index) => ({ ...authorization, no: authorizations.length - index })),
|
|
||||||
[authorizations]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<NumberedDeviceAuthorization>>>(
|
|
||||||
() => [
|
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
header: '대상 사용자',
|
|
||||||
flexible: true,
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold">{row.authUserName || row.accountEmail || '-'}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{row.accountEmail || '-'}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ key: 'code', header: '등록 키', width: 150, cell: (row) => <span className="font-mono font-semibold tracking-[0.12em]">{row.userCode}</span> },
|
|
||||||
{ key: 'expires', header: '만료', width: 190, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.expiresAt)}</span> },
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: '상태',
|
|
||||||
width: 120,
|
|
||||||
cell: (row) => <StatusBadge status={row.status === 'APPROVED' ? '발급됨' : row.status === 'CANCELLED' ? '취소됨' : row.status === 'CONSUMED' ? '사용됨' : row.status === 'EXPIRED' ? '만료됨' : row.status} badgeTone={row.status === 'APPROVED' ? 'green' : row.status === 'CANCELLED' || row.status === 'EXPIRED' ? 'neutral' : undefined} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
|
||||||
cell: (row) => row.status === 'APPROVED' ? <div className="flex justify-end"><Button variant="ghost" className="h-8 px-2 text-[13px] text-rose-300 hover:text-rose-200" onClick={() => setCancelTarget(row)}>취소</Button></div> : null,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="플랫폼 관리" title="플랫폼 등록" description="에이전트 설치에 사용할 사용자별 6자리 등록 키를 발급하고 관리합니다." />
|
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
||||||
<Card className="mb-4">
|
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
||||||
<CardHeader title="플랫폼 등록 키" description="대상 사용자를 선택한 뒤 발급한 키와 계정 이메일을 에이전트 설치 화면에 입력합니다." />
|
|
||||||
<div className="border-t border-border p-5">
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
|
||||||
<label className="relative min-w-0 flex-1">
|
|
||||||
<span className="mb-2 block text-xs font-medium text-muted-foreground">대상 사용자</span>
|
|
||||||
<input
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
||||||
placeholder="이름 또는 이메일 검색"
|
|
||||||
value={selectedUser ? `${selectedUser.name || selectedUser.email} · ${selectedUser.email}` : keyword}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSelectedUser(null);
|
|
||||||
setKeyword(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{users.length ? (
|
|
||||||
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-card py-1 shadow-xl">
|
|
||||||
{users.map((user) => (
|
|
||||||
<button key={user.id} type="button" className="block w-full px-3 py-2 text-left hover:bg-muted" onClick={() => { setSelectedUser(user); setKeyword(''); setUsers([]); }}>
|
|
||||||
<span className="block text-sm font-medium">{user.name || user.email}</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.email}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
{selectedUser ? <Button variant="outline" className="h-9 px-3 text-[13px]" onClick={() => { setSelectedUser(null); setKeyword(''); }}>선택 해제</Button> : null}
|
|
||||||
<Button className="h-9 px-3 text-[13px]" disabled={!selectedUser || issuing} onClick={() => void issue()}>
|
|
||||||
키 발급
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? <p className="mt-3 text-sm text-rose-300">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<DataTableCard
|
|
||||||
title="등록 키 목록"
|
|
||||||
description="활성 키와 최근 24시간 내 발급된 키를 확인합니다."
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
searchPlaceholder="사용자명, 이메일, 등록 키 검색"
|
|
||||||
getSearchText={(row) => `${row.authUserName ?? ''} ${row.accountEmail ?? ''} ${row.userCode} ${row.status}`}
|
|
||||||
getRowKey={(row) => row.id}
|
|
||||||
pageSize={6}
|
|
||||||
minWidth={920}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage="발급된 플랫폼 등록 키가 없습니다."
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
open={Boolean(cancelTarget)}
|
|
||||||
title="등록 키 취소"
|
|
||||||
onClose={() => setCancelTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setCancelTarget(null)}>닫기</Button>
|
|
||||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" onClick={() => void cancel()}>취소</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">선택한 등록 키를 취소하면 에이전트 설치에 더 이상 사용할 수 없습니다.</p>
|
|
||||||
<p className="mt-3 font-mono text-sm font-semibold tracking-[0.12em] text-foreground">{cancelTarget?.userCode}</p>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1079,74 +864,26 @@ export function AgentRegistrationPage() {
|
|||||||
export function AgentInstallStatusPage() {
|
export function AgentInstallStatusPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="플랫폼 관리" title="연결 상태" description="플랫폼별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||||
<AgentTable title="연결 상태" description="플랫폼별 Agent 연결 상태를 확인합니다." />
|
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentSshKeysPage() {
|
export function AgentSshKeysPage() {
|
||||||
const [keys, setKeys] = useState<PlatformSshKey[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedKey, setSelectedKey] = useState<PlatformSshKey | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPlatformSshKeys()
|
|
||||||
.then(setKeys)
|
|
||||||
.catch(() => setKeys([]))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const rows = useMemo<NumberedPlatformSshKey[]>(
|
|
||||||
() => keys.map((key, index) => ({ ...key, no: keys.length - index })),
|
|
||||||
[keys]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||||
<DataTableCard
|
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
||||||
title="SSH 키 목록"
|
</>
|
||||||
description="등록 플랫폼 계정에 연결된 SSH 키입니다."
|
);
|
||||||
rows={rows}
|
}
|
||||||
columns={[
|
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
export function AgentWorkspacePage() {
|
||||||
{ key: 'account', header: '계정', flexible: true, cell: (row) => row.accountEmail },
|
return (
|
||||||
{ key: 'label', header: '이름', width: 180, cell: (row) => row.label || '-' },
|
<>
|
||||||
{ key: 'type', header: '유형', width: 140, cell: (row) => row.keyType || '-' },
|
<PageTitle eyebrow="Agent" title="Workspace 설정" description="장비별 작업 디렉터리와 동기화 정책을 관리합니다." />
|
||||||
{ key: 'updated', header: '최근 등록', width: 180, cell: (row) => formatTailscaleLastSeen(row.lastProvisionedAt) },
|
<AgentPlaceholder title="Workspace 설정" description="계정별 workspace 경로와 동기화 상태입니다." />
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 px-0"
|
|
||||||
title="메뉴"
|
|
||||||
aria-label="메뉴"
|
|
||||||
onClick={() => setSelectedKey(row)}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
getRowKey={(row) => row.id}
|
|
||||||
getSearchText={(row) => `${row.accountEmail} ${row.label ?? ''} ${row.publicKeyHash}`}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage="등록된 SSH 키가 없습니다."
|
|
||||||
/>
|
|
||||||
<Modal open={Boolean(selectedKey)} title="SSH 키 지문" onClose={() => setSelectedKey(null)}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-foreground">{selectedKey?.label || selectedKey?.accountEmail}</p>
|
|
||||||
<p className="break-all rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-foreground">
|
|
||||||
{selectedKey?.publicKeyHash}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1165,7 +902,7 @@ export function AgentCommandsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="플랫폼 관리" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="최근 제어 작업"
|
title="최근 제어 작업"
|
||||||
rows={runtimeCommands}
|
rows={runtimeCommands}
|
||||||
@@ -1178,6 +915,17 @@ export function AgentCommandsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={title} description={description} />
|
||||||
|
<div className="p-5">
|
||||||
|
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MonitoringPage() {
|
export function MonitoringPage() {
|
||||||
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { apiRequest } from '../../lib/api';
|
|
||||||
|
|
||||||
export type PlatformDevice = {
|
|
||||||
id: string;
|
|
||||||
accountEmail: string;
|
|
||||||
hostname: string;
|
|
||||||
tailscaleHostname: string;
|
|
||||||
sshUser?: string | null;
|
|
||||||
workspaceRoot?: string | null;
|
|
||||||
usageType: string;
|
|
||||||
os?: string | null;
|
|
||||||
arch?: string | null;
|
|
||||||
distro?: string | null;
|
|
||||||
agentVersion?: string | null;
|
|
||||||
agentUpdatedAt?: string | null;
|
|
||||||
agentLastSeenAt?: string | null;
|
|
||||||
installedAt: string;
|
|
||||||
lastProvisionedAt: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlatformSshKey = {
|
|
||||||
id: string;
|
|
||||||
accountEmail: string;
|
|
||||||
label?: string | null;
|
|
||||||
publicKeyHash: string;
|
|
||||||
keyType?: string | null;
|
|
||||||
lastProvisionedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlatformRegistrationUser = {
|
|
||||||
id: string;
|
|
||||||
name?: string | null;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DeviceAuthorization = {
|
|
||||||
id: string;
|
|
||||||
userCode: string;
|
|
||||||
status: string;
|
|
||||||
authUserId?: string | null;
|
|
||||||
accountEmail?: string | null;
|
|
||||||
authUserName?: string | null;
|
|
||||||
expiresAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loadPlatformDevices() {
|
|
||||||
const response = await apiRequest<{ devices?: PlatformDevice[] }>('/agents/devices');
|
|
||||||
return response.devices ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPlatformSshKeys() {
|
|
||||||
const response = await apiRequest<{ sshKeys?: PlatformSshKey[] }>('/agents/ssh-keys');
|
|
||||||
return response.sshKeys ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchPlatformRegistrationUsers(keyword: string) {
|
|
||||||
const response = await apiRequest<{ users?: PlatformRegistrationUser[] }>(`/agents/registration-users?keyword=${encodeURIComponent(keyword)}`);
|
|
||||||
return response.users ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadDeviceAuthorizations() {
|
|
||||||
const response = await apiRequest<{ authorizations?: DeviceAuthorization[] }>('/agents/device-authorizations');
|
|
||||||
return response.authorizations ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDeviceAuthorization(userId: string) {
|
|
||||||
const response = await apiRequest<{ authorization: DeviceAuthorization }>('/agents/device-authorizations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ userId }),
|
|
||||||
});
|
|
||||||
return response.authorization;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelDeviceAuthorization(id: string) {
|
|
||||||
return apiRequest<{ ok: boolean }>(`/agents/device-authorizations/${id}/cancel`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import { useEffect, useState, type MouseEvent } from 'react';
|
import { useEffect, useState, type MouseEvent } from 'react';
|
||||||
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2, UserCog } from 'lucide-react';
|
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
||||||
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions';
|
||||||
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||||
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
||||||
import { LoadingModal } from '../operations/operation-utils';
|
import { LoadingModal } from '../operations/operation-utils';
|
||||||
import { loadAdmins, updateAdminAppPermissions, type AdminAccount } from '../operations/operations-api';
|
|
||||||
|
|
||||||
type ComponentPortForm = {
|
type ComponentPortForm = {
|
||||||
name: string;
|
name: string;
|
||||||
port: string;
|
port: string;
|
||||||
targetPort: string;
|
targetPort: string;
|
||||||
nodePort: string;
|
|
||||||
protocol: string;
|
protocol: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ const emptyComponentPort = (): ComponentPortForm => ({
|
|||||||
name: '',
|
name: '',
|
||||||
port: '',
|
port: '',
|
||||||
targetPort: '',
|
targetPort: '',
|
||||||
nodePort: '',
|
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,40 +73,18 @@ function getTone(status: string) {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStatus(status?: string | null, launcherLinked?: boolean) {
|
function formatStatus(status?: string | null) {
|
||||||
if (status === 'DISABLED' || status === 'INACTIVE') return '비활성';
|
|
||||||
if (launcherLinked) return '활성';
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
ACTIVE: '활성',
|
ACTIVE: '준비됨',
|
||||||
READY: '준비됨',
|
READY: '준비됨',
|
||||||
|
INACTIVE: '비활성',
|
||||||
|
DISABLED: '비활성',
|
||||||
ERROR: '오류'
|
ERROR: '오류'
|
||||||
};
|
};
|
||||||
|
|
||||||
return status ? statusMap[status] ?? status : '-';
|
return status ? statusMap[status] ?? status : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppAdminAvatar({ admin }: { admin: AdminAccount }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
|
||||||
|
|
||||||
if (admin.avatarUrl && !failed) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={admin.avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-8 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadApps() {
|
function loadApps() {
|
||||||
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -277,7 +252,6 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: port.name ?? '',
|
name: port.name ?? '',
|
||||||
port: port.port ? String(port.port) : '',
|
port: port.port ? String(port.port) : '',
|
||||||
targetPort: port.targetPort ? String(port.targetPort) : '',
|
targetPort: port.targetPort ? String(port.targetPort) : '',
|
||||||
nodePort: port.nodePort ? String(port.nodePort) : '',
|
|
||||||
protocol: port.protocol ?? 'TCP'
|
protocol: port.protocol ?? 'TCP'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -287,7 +261,6 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: 'http',
|
name: 'http',
|
||||||
port: String(component.port),
|
port: String(component.port),
|
||||||
targetPort: String(component.targetPort ?? component.port),
|
targetPort: String(component.targetPort ?? component.port),
|
||||||
nodePort: '',
|
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -298,20 +271,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
function formatComponentPorts(component: ApiAppComponent) {
|
function formatComponentPorts(component: ApiAppComponent) {
|
||||||
const ports = componentPortForms(component).filter((port) => port.port);
|
const ports = componentPortForms(component).filter((port) => port.port);
|
||||||
if (ports.length === 0) return '-';
|
if (ports.length === 0) return '-';
|
||||||
return ports.map((port) => {
|
return ports.map((port) => `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}`).join(', ');
|
||||||
const nodePort = port.nodePort ? ` / node ${port.nodePort}` : '';
|
|
||||||
return `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}${nodePort}`;
|
|
||||||
}).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function appKeyFromName(name: string) {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppManagementPage() {
|
export function AppManagementPage() {
|
||||||
@@ -357,13 +317,6 @@ export function AppManagementPage() {
|
|||||||
deleteKubernetesResources: false,
|
deleteKubernetesResources: false,
|
||||||
deletePersistentVolumes: false
|
deletePersistentVolumes: false
|
||||||
});
|
});
|
||||||
const [isAppAdminsOpen, setIsAppAdminsOpen] = useState(false);
|
|
||||||
const [appAdmins, setAppAdmins] = useState<AdminAccount[]>([]);
|
|
||||||
const [appAdminPermissions, setAppAdminPermissions] = useState<Record<string, string[]>>({});
|
|
||||||
const [appAdminsLoading, setAppAdminsLoading] = useState(false);
|
|
||||||
const [appAdminSavingId, setAppAdminSavingId] = useState('');
|
|
||||||
const [appAdminSearch, setAppAdminSearch] = useState('');
|
|
||||||
const [appAdminConfirm, setAppAdminConfirm] = useState<{ admin: AdminAccount; allowed: boolean } | null>(null);
|
|
||||||
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
||||||
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
||||||
@@ -381,20 +334,6 @@ export function AppManagementPage() {
|
|||||||
const workloads = runtimeResources.filter(isWorkloadResource);
|
const workloads = runtimeResources.filter(isWorkloadResource);
|
||||||
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
||||||
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
||||||
const selectedAppAuthorizedAdmins = selectedApp
|
|
||||||
? appAdmins.filter((admin) => isOwnerAdmin(admin) || (appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id))
|
|
||||||
: [];
|
|
||||||
const appAdminAddOptions = selectedApp
|
|
||||||
? appAdmins.filter((admin) => (
|
|
||||||
admin.isActive
|
|
||||||
&& !isOwnerAdmin(admin)
|
|
||||||
&& !(appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id)
|
|
||||||
))
|
|
||||||
: [];
|
|
||||||
const appAdminSearchKeyword = appAdminSearch.trim().toLowerCase();
|
|
||||||
const appAdminSearchResults = appAdminSearchKeyword.length >= 2
|
|
||||||
? appAdminAddOptions.filter((admin) => `${admin.name} ${admin.email} ${admin.role}`.toLowerCase().includes(appAdminSearchKeyword)).slice(0, 8)
|
|
||||||
: [];
|
|
||||||
const canDeleteVolume = Boolean(
|
const canDeleteVolume = Boolean(
|
||||||
deletingVolumeWorkload &&
|
deletingVolumeWorkload &&
|
||||||
selectedVolumeClaimName &&
|
selectedVolumeClaimName &&
|
||||||
@@ -569,13 +508,6 @@ export function AppManagementPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeNewAppName = (name: string) => {
|
|
||||||
const appKey = appKeyFromName(name);
|
|
||||||
setNewAppName(name);
|
|
||||||
setNewAppKey(appKey);
|
|
||||||
setNewAppNamespace(appKey ? `stdo-${appKey}` : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
if (!newAppKey.trim() || !newAppName.trim()) return;
|
if (!newAppKey.trim() || !newAppName.trim()) return;
|
||||||
|
|
||||||
@@ -671,7 +603,6 @@ export function AppManagementPage() {
|
|||||||
name: item.name.trim() || undefined,
|
name: item.name.trim() || undefined,
|
||||||
port: item.port ? Number(item.port) : undefined,
|
port: item.port ? Number(item.port) : undefined,
|
||||||
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
|
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
|
||||||
nodePort: componentServiceType === 'NodePort' && item.nodePort ? Number(item.nodePort) : undefined,
|
|
||||||
protocol: item.protocol || 'TCP'
|
protocol: item.protocol || 'TCP'
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.port);
|
.filter((item) => item.port);
|
||||||
@@ -928,57 +859,6 @@ export function AppManagementPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAppAdmins = () => {
|
|
||||||
if (!selectedApp) return;
|
|
||||||
setIsAppAdminsOpen(true);
|
|
||||||
setAppAdminsLoading(true);
|
|
||||||
setAppAdminSearch('');
|
|
||||||
loadAdmins()
|
|
||||||
.then((admins) => {
|
|
||||||
setAppAdmins(admins);
|
|
||||||
setAppAdminPermissions(Object.fromEntries(admins.map((admin) => [admin.id, admin.appPermissions ?? []])));
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
setAppAdmins([]);
|
|
||||||
setAppAdminPermissions({});
|
|
||||||
toast({ type: 'error', title: '관리자 조회 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setAppAdminsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
|
||||||
if (isOwnerAdmin(admin) || appAdminSavingId) return;
|
|
||||||
setAppAdminConfirm({ admin, allowed });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
|
||||||
if (!selectedApp || isOwnerAdmin(admin) || appAdminSavingId) return;
|
|
||||||
const currentAppIds = appAdminPermissions[admin.id] ?? admin.appPermissions ?? [];
|
|
||||||
const nextAppIds = allowed
|
|
||||||
? [...new Set([...currentAppIds, selectedApp.id])]
|
|
||||||
: currentAppIds.filter((appId) => appId !== selectedApp.id);
|
|
||||||
|
|
||||||
setAppAdminSavingId(admin.id);
|
|
||||||
updateAdminAppPermissions(admin.id, nextAppIds)
|
|
||||||
.then((result) => {
|
|
||||||
const savedAppIds = result.appIds ?? nextAppIds;
|
|
||||||
setAppAdminPermissions((current) => ({ ...current, [admin.id]: savedAppIds }));
|
|
||||||
setAppAdmins((current) => current.map((item) => item.id === admin.id ? { ...item, appPermissions: savedAppIds } : item));
|
|
||||||
setAdminAppPermissions(admin, savedAppIds);
|
|
||||||
setAppAdminSearch('');
|
|
||||||
setAppAdminConfirm(null);
|
|
||||||
toast({
|
|
||||||
type: 'success',
|
|
||||||
title: allowed ? '관리자 추가' : '관리자 해제',
|
|
||||||
message: `${admin.name} 관리자 권한이 저장되었습니다.`
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '앱 관리자 저장 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setAppAdminSavingId(''));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -986,14 +866,9 @@ export function AppManagementPage() {
|
|||||||
title="앱 관리"
|
title="앱 관리"
|
||||||
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={openAppAdmins}>
|
|
||||||
<UserCog className="size-4" />관리자
|
|
||||||
</Button>
|
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
||||||
<Plus className="size-4" />앱 등록
|
<Plus className="size-4" />앱 등록
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1031,9 +906,7 @@ export function AppManagementPage() {
|
|||||||
<td className="px-5 py-3">{app.name}</td>
|
<td className="px-5 py-3">{app.name}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td>
|
<td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td>
|
||||||
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3"><Badge tone={getTone(app.status)}>{formatStatus(app.status)}</Badge></td>
|
||||||
<Badge tone={getTone(formatStatus(app.status, app.launcherLinked))}>{formatStatus(app.status, app.launcherLinked)}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -1079,7 +952,7 @@ export function AppManagementPage() {
|
|||||||
['네임스페이스', selectedApp.namespace],
|
['네임스페이스', selectedApp.namespace],
|
||||||
['연결 소스', `${linkedComponents}개`],
|
['연결 소스', `${linkedComponents}개`],
|
||||||
['구성요소', `${totalComponents}개`],
|
['구성요소', `${totalComponents}개`],
|
||||||
['상태', formatStatus(selectedApp.status, selectedApp.launcherLinked)]
|
['상태', formatStatus(selectedApp.status)]
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="rounded-md border border-border bg-muted/20 p-4">
|
<div key={label} className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
@@ -1106,7 +979,7 @@ export function AppManagementPage() {
|
|||||||
title="앱 구성요소"
|
title="앱 구성요소"
|
||||||
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
||||||
action={
|
action={
|
||||||
<Button className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1135,6 +1008,7 @@ export function AppManagementPage() {
|
|||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} />
|
<span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} />
|
||||||
{component.name}
|
{component.name}
|
||||||
|
{running ? <Badge tone="green">실행 중</Badge> : null}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td>
|
<td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td>
|
||||||
@@ -1217,6 +1091,7 @@ export function AppManagementPage() {
|
|||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} />
|
<span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} />
|
||||||
<span className="text-sm text-foreground">{resource.name}</span>
|
<span className="text-sm text-foreground">{resource.name}</span>
|
||||||
|
<Badge tone={runtimeStatusTone(resource, stopped)}>{runtimeStatusLabel(resource, stopped)}</Badge>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-foreground">{resource.kind}</td>
|
<td className="px-5 py-3 text-foreground">{resource.kind}</td>
|
||||||
@@ -1291,159 +1166,24 @@ export function AppManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAppAdminsOpen ? (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
|
||||||
<div className="flex max-h-[86vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-card shadow-xl">
|
|
||||||
<div className="border-b border-border px-5 py-3">
|
|
||||||
<h2 className="text-sm font-semibold">관리자</h2>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{selectedApp?.name ?? '-'} · {selectedApp?.key ?? '-'} 앱에 접근할 수 있는 관리자입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5">
|
|
||||||
<div className="rounded-md border border-border p-3">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">관리자 추가</p>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
||||||
value={appAdminSearch}
|
|
||||||
onChange={(event) => setAppAdminSearch(event.target.value)}
|
|
||||||
placeholder="이름 또는 이메일 입력"
|
|
||||||
disabled={appAdminsLoading || appAdminAddOptions.length === 0 || Boolean(appAdminSavingId)}
|
|
||||||
/>
|
|
||||||
{appAdminSearchKeyword.length >= 2 ? (
|
|
||||||
<div className="mt-2 overflow-hidden rounded-md border border-border">
|
|
||||||
{appAdminSearchResults.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
|
||||||
) : (
|
|
||||||
appAdminSearchResults.map((admin) => (
|
|
||||||
<div
|
|
||||||
key={admin.id}
|
|
||||||
className="flex w-full items-center justify-between gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/20"
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
|
||||||
<AppAdminAvatar admin={admin} />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-semibold text-foreground">{admin.name}</span>
|
|
||||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{admin.email}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Badge tone="blue">{admin.role}</Badge>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
disabled={Boolean(appAdminSavingId)}
|
|
||||||
onClick={() => requestAppAdminAccess(admin, true)}
|
|
||||||
>
|
|
||||||
선택
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-md border border-border">
|
|
||||||
{appAdminsLoading ? (
|
|
||||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">관리자 목록을 불러오는 중입니다.</div>
|
|
||||||
) : selectedAppAuthorizedAdmins.length === 0 ? (
|
|
||||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">권한이 있는 관리자가 없습니다.</div>
|
|
||||||
) : (
|
|
||||||
selectedAppAuthorizedAdmins.map((admin) => {
|
|
||||||
const owner = isOwnerAdmin(admin);
|
|
||||||
const saving = appAdminSavingId === admin.id;
|
|
||||||
return (
|
|
||||||
<div key={admin.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<AppAdminAvatar admin={admin} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{admin.name}</p>
|
|
||||||
<Badge tone={owner ? 'green' : 'blue'}>{owner ? '오너' : '관리자'}</Badge>
|
|
||||||
{!admin.isActive ? <Badge tone="red">비활성</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{admin.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{owner ? (
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">전체 앱 접근</span>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 shrink-0 border-rose-500/50 px-3 text-[13px] text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
|
||||||
disabled={saving || Boolean(appAdminSavingId)}
|
|
||||||
onClick={() => requestAppAdminAccess(admin, false)}
|
|
||||||
>
|
|
||||||
{saving ? '저장 중' : '해제'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
|
||||||
<Button variant="outline" onClick={() => setIsAppAdminsOpen(false)} disabled={Boolean(appAdminSavingId)}>
|
|
||||||
닫기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(appAdminConfirm)}
|
|
||||||
title={appAdminConfirm?.allowed ? '관리자 권한 추가' : '관리자 권한 해제'}
|
|
||||||
onClose={() => {
|
|
||||||
if (!appAdminSavingId) setAppAdminConfirm(null);
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" disabled={Boolean(appAdminSavingId)} onClick={() => setAppAdminConfirm(null)}>취소</Button>
|
|
||||||
<Button
|
|
||||||
className={appAdminConfirm?.allowed ? undefined : 'border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'}
|
|
||||||
disabled={!appAdminConfirm || Boolean(appAdminSavingId)}
|
|
||||||
onClick={() => appAdminConfirm && setAppAdminAccess(appAdminConfirm.admin, appAdminConfirm.allowed)}
|
|
||||||
>
|
|
||||||
{appAdminSavingId ? '저장 중' : appAdminConfirm?.allowed ? '추가' : '해제'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
|
||||||
<p className="text-sm font-semibold text-foreground">{appAdminConfirm?.admin.name ?? '-'}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{appAdminConfirm?.admin.email ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{appAdminConfirm?.allowed
|
|
||||||
? `${selectedApp?.name ?? '-'} 앱 접근 권한을 추가합니다.`
|
|
||||||
: `${selectedApp?.name ?? '-'} 앱 접근 권한을 해제합니다.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{isCreateAppOpen ? (
|
{isCreateAppOpen ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||||
<div className="border-b border-border px-5 py-3">
|
<div className="border-b border-border px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold">앱 등록</h2>
|
<h2 className="text-sm font-semibold">앱 그룹 등록</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 이름 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">앱 Key</span>
|
||||||
<input value={newAppName} onChange={(event) => changeNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={newAppKey} onChange={(event) => setNewAppKey(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 Key</span>
|
<span className="text-xs font-medium text-muted-foreground">앱 이름</span>
|
||||||
<input value={newAppKey} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
<input value={newAppName} onChange={(event) => setNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||||
<input value={newAppNamespace} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
<input value={newAppNamespace} onChange={(event) => setNewAppNamespace(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
@@ -1475,11 +1215,11 @@ export function AppManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">구성요소명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">구성요소명</span>
|
||||||
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
||||||
<select
|
<select
|
||||||
value={componentType}
|
value={componentType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1506,7 +1246,7 @@ export function AppManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이미지 소스 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이미지 소스</span>
|
||||||
<select
|
<select
|
||||||
value={componentImageSource}
|
value={componentImageSource}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1530,7 +1270,7 @@ export function AppManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">워크로드 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">워크로드</span>
|
||||||
<select
|
<select
|
||||||
value={componentWorkloadType}
|
value={componentWorkloadType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1571,7 +1311,7 @@ export function AppManagementPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이미지명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이미지명</span>
|
||||||
<input
|
<input
|
||||||
value={componentExternalImage}
|
value={componentExternalImage}
|
||||||
onChange={(event) => setComponentExternalImage(event.target.value)}
|
onChange={(event) => setComponentExternalImage(event.target.value)}
|
||||||
@@ -1603,15 +1343,7 @@ export function AppManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{componentPorts.map((item, index) => (
|
{componentPorts.map((item, index) => (
|
||||||
<div
|
<div key={index} className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]">
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
'grid min-w-0 gap-2',
|
|
||||||
componentServiceType === 'NodePort'
|
|
||||||
? 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.65fr)_2.5rem]'
|
|
||||||
: 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
||||||
@@ -1634,17 +1366,6 @@ export function AppManagementPage() {
|
|||||||
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
placeholder="컨테이너 포트"
|
placeholder="컨테이너 포트"
|
||||||
/>
|
/>
|
||||||
{componentServiceType === 'NodePort' ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="30000"
|
|
||||||
max="32767"
|
|
||||||
value={item.nodePort}
|
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, nodePort: event.target.value } : port))}
|
|
||||||
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
||||||
placeholder="노드 포트"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<select
|
<select
|
||||||
value={item.protocol}
|
value={item.protocol}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { Requester } from '../../components/requester';
|
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||||
import { cancelBuildJob, createBuildJob, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||||
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
|
|
||||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||||
@@ -29,7 +28,7 @@ function buildTargetForType(type?: string | null) {
|
|||||||
|
|
||||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||||
if (!component) return '-';
|
if (!component) return '-';
|
||||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : '<commit-sha>';
|
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
||||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||||
return `localhost:5000/${component.name}:${suffix}`;
|
return `localhost:5000/${component.name}:${suffix}`;
|
||||||
@@ -64,12 +63,6 @@ function resultArtifact(job: BuildJob) {
|
|||||||
return job.image || job.artifact || '-';
|
return job.image || job.artifact || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQuickDeployable(job: BuildJob) {
|
|
||||||
return job.status === 'SUCCEEDED'
|
|
||||||
&& Boolean(job.image)
|
|
||||||
&& ['CONTAINER', 'STATIC_WEB'].includes(job.component.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobTime(job: BuildJob) {
|
function jobTime(job: BuildJob) {
|
||||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
@@ -101,12 +94,9 @@ export function BuildManagementPage() {
|
|||||||
const [commitSha, setCommitSha] = useState('');
|
const [commitSha, setCommitSha] = useState('');
|
||||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
||||||
const [quickDeployTarget, setQuickDeployTarget] = useState<BuildJob | null>(null);
|
|
||||||
const [quickDeployNamespace, setQuickDeployNamespace] = useState('');
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deletingJob, setDeletingJob] = useState(false);
|
const [deletingJob, setDeletingJob] = useState(false);
|
||||||
const [quickDeploying, setQuickDeploying] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [range, setRange] = useState<BuildRange>('week');
|
const [range, setRange] = useState<BuildRange>('week');
|
||||||
const [totalJobs, setTotalJobs] = useState(0);
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
@@ -285,29 +275,6 @@ export function BuildManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openQuickDeploy = (job: BuildJob) => {
|
|
||||||
setQuickDeployTarget(job);
|
|
||||||
setQuickDeployNamespace(job.component.app.namespace || '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const quickDeploy = async () => {
|
|
||||||
if (!quickDeployTarget || !quickDeployNamespace.trim() || quickDeploying) return;
|
|
||||||
setQuickDeploying(true);
|
|
||||||
try {
|
|
||||||
await createQuickDeployment({
|
|
||||||
buildJobId: quickDeployTarget.id,
|
|
||||||
environment: 'prod',
|
|
||||||
namespace: quickDeployNamespace.trim()
|
|
||||||
});
|
|
||||||
toast({ type: 'success', message: '빠른 배포 작업이 실행되었습니다.' });
|
|
||||||
setQuickDeployTarget(null);
|
|
||||||
} catch (error) {
|
|
||||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빠른 배포에 실패했습니다.' });
|
|
||||||
} finally {
|
|
||||||
setQuickDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -416,7 +383,6 @@ export function BuildManagementPage() {
|
|||||||
<span className="font-medium">{job.component.name}</span>
|
<span className="font-medium">{job.component.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<Requester item={job} />
|
|
||||||
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
@@ -436,11 +402,6 @@ export function BuildManagementPage() {
|
|||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isQuickDeployable(job) ? (
|
|
||||||
<Button variant="outline" className="size-8 px-0" title="빠른 배포" aria-label="빠른 배포" onClick={() => openQuickDeploy(job)}>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{isBuildDone(job.status) ? (
|
{isBuildDone(job.status) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -543,34 +504,6 @@ export function BuildManagementPage() {
|
|||||||
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
open={Boolean(quickDeployTarget)}
|
|
||||||
title="빠른 배포"
|
|
||||||
onClose={() => {
|
|
||||||
if (!quickDeploying) setQuickDeployTarget(null);
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" disabled={quickDeploying} onClick={() => setQuickDeployTarget(null)}>취소</Button>
|
|
||||||
<Button disabled={quickDeploying || !quickDeployNamespace.trim()} onClick={quickDeploy}>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
{quickDeploying ? '배포 중' : '배포'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<p className="text-muted-foreground">릴리즈를 만들지 않고 선택한 빌드 이미지로 현재 워크로드를 교체합니다. 런처 앱 버전은 갱신하지 않습니다.</p>
|
|
||||||
<div className="grid gap-2 rounded-md border border-border bg-muted/20 p-4 text-xs">
|
|
||||||
<div className="flex items-center justify-between gap-4"><span className="text-muted-foreground">구성요소</span><span className="font-medium">{quickDeployTarget?.component.name ?? '-'}</span></div>
|
|
||||||
<div className="min-w-0"><p className="text-muted-foreground">이미지</p><p className="mt-1 truncate font-mono font-medium">{quickDeployTarget?.image ?? '-'}</p></div>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={quickDeployNamespace} disabled={quickDeploying} onChange={(event) => setQuickDeployNamespace(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="빌드 실행 확인"
|
title="빌드 실행 확인"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { Requester } from '../../components/requester';
|
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, DeploymentJob } from '../../types/application';
|
import type { AppRelease, DeploymentJob } from '../../types/application';
|
||||||
@@ -36,13 +35,11 @@ type ImageTags = {
|
|||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function releaseArtifacts(release?: AppRelease | null, appName?: string) {
|
function releaseArtifacts(release?: AppRelease | null) {
|
||||||
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
||||||
const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => (
|
const entries = Object.entries(imageTags?.images ?? {});
|
||||||
!/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim())
|
|
||||||
));
|
|
||||||
if (entries.length) return entries;
|
if (entries.length) return entries;
|
||||||
if (release?.artifact) return [[release.app?.name ?? appName ?? 'artifact', release.artifact]];
|
if (release?.artifact) return [[release.app.name, release.artifact]];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +76,6 @@ export function DeploymentManagementPage() {
|
|||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
const [releaseId, setReleaseId] = useState('');
|
const [releaseId, setReleaseId] = useState('');
|
||||||
const [namespace, setNamespace] = useState('');
|
const [namespace, setNamespace] = useState('');
|
||||||
const [replicas, setReplicas] = useState('1');
|
|
||||||
const [cpuRequest, setCpuRequest] = useState('100m');
|
|
||||||
const [cpuLimit, setCpuLimit] = useState('500m');
|
|
||||||
const [memoryRequest, setMemoryRequest] = useState('128Mi');
|
|
||||||
const [memoryLimit, setMemoryLimit] = useState('256Mi');
|
|
||||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||||
@@ -165,12 +157,6 @@ export function DeploymentManagementPage() {
|
|||||||
setReleaseId(release.id);
|
setReleaseId(release.id);
|
||||||
setNamespace(release.app.namespace || '');
|
setNamespace(release.app.namespace || '');
|
||||||
}
|
}
|
||||||
if (!release && !releaseId && !releases[0]?.app?.namespace) setNamespace('');
|
|
||||||
setReplicas('1');
|
|
||||||
setCpuRequest('100m');
|
|
||||||
setCpuLimit('500m');
|
|
||||||
setMemoryRequest('128Mi');
|
|
||||||
setMemoryLimit('256Mi');
|
|
||||||
setUpdateLauncherAppVersion(true);
|
setUpdateLauncherAppVersion(true);
|
||||||
setDeployModalOpen(true);
|
setDeployModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -178,22 +164,8 @@ export function DeploymentManagementPage() {
|
|||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
if (deploying) return;
|
if (deploying) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
const requestedReplicas = Number.parseInt(replicas, 10);
|
|
||||||
const resources = {
|
|
||||||
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
|
||||||
...(cpuRequest.trim() ? { cpuRequest: cpuRequest.trim() } : {}),
|
|
||||||
...(cpuLimit.trim() ? { cpuLimit: cpuLimit.trim() } : {}),
|
|
||||||
...(memoryRequest.trim() ? { memoryRequest: memoryRequest.trim() } : {}),
|
|
||||||
...(memoryLimit.trim() ? { memoryLimit: memoryLimit.trim() } : {})
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const result = await createDeployment({
|
const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion });
|
||||||
releaseId,
|
|
||||||
environment: 'prod',
|
|
||||||
namespace,
|
|
||||||
updateLauncherAppVersion,
|
|
||||||
resources: Object.keys(resources).length ? resources : undefined
|
|
||||||
});
|
|
||||||
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
||||||
setSelectedDeployment(result.deploymentJob);
|
setSelectedDeployment(result.deploymentJob);
|
||||||
setDeployModalOpen(false);
|
setDeployModalOpen(false);
|
||||||
@@ -263,7 +235,7 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{pagedDeployments.map((deployment, index) => {
|
{pagedDeployments.map((deployment, index) => {
|
||||||
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name);
|
const artifacts = releaseArtifacts(deployment.release);
|
||||||
return (
|
return (
|
||||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -272,12 +244,12 @@ export function DeploymentManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
||||||
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
||||||
<span className="text-base font-medium">{deployment.app.name}</span>
|
<span className="text-base font-medium">{deployment.app.name}</span>
|
||||||
<span className="text-base font-medium">{deployment.release?.version || '빠른 배포'}</span>
|
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
||||||
<Requester item={deployment} label="요청자" />
|
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,60 +356,6 @@ export function DeploymentManagementPage() {
|
|||||||
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={replicas}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setReplicas(event.target.value)}
|
|
||||||
placeholder="1"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={cpuRequest}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setCpuRequest(event.target.value)}
|
|
||||||
placeholder="예: 100m"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={cpuLimit}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setCpuLimit(event.target.value)}
|
|
||||||
placeholder="예: 500m"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={memoryRequest}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setMemoryRequest(event.target.value)}
|
|
||||||
placeholder="예: 256Mi"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={memoryLimit}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setMemoryLimit(event.target.value)}
|
|
||||||
placeholder="예: 512Mi"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { Requester } from '../../components/requester';
|
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
||||||
@@ -313,7 +312,6 @@ export function MigrationManagementPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
||||||
<Requester item={job} />
|
|
||||||
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { Requester } from '../../components/requester';
|
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, BuildJob } from '../../types/application';
|
import type { AppRelease, BuildJob } from '../../types/application';
|
||||||
@@ -30,43 +29,17 @@ function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'bl
|
|||||||
type ImageTags = {
|
type ImageTags = {
|
||||||
images?: Record<string, string>;
|
images?: Record<string, string>;
|
||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
buildJobs?: Array<{
|
|
||||||
component?: string;
|
|
||||||
type?: string;
|
|
||||||
}>;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
promotion?: {
|
promotion?: {
|
||||||
sourceBuildJobIds?: string[];
|
sourceBuildJobIds?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function releaseBuildComponentTypes(imageTags: ImageTags | null | undefined) {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const job of imageTags?.buildJobs ?? []) {
|
|
||||||
if (job.component && job.type) map.set(job.component, job.type);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntimeOnlyImage(value: string) {
|
|
||||||
return /^(postgres|redis|mysql|mariadb|mongo|nginx|rabbitmq)(:|@)/i.test(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReleaseArtifactVisible(componentName: string, value: string, componentTypes: Map<string, string>) {
|
|
||||||
const type = componentTypes.get(componentName);
|
|
||||||
return type !== 'DATABASE' && type !== 'MIGRATION' && !isRuntimeOnlyImage(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseArtifacts(release: AppRelease) {
|
function releaseArtifacts(release: AppRelease) {
|
||||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||||
const componentTypes = releaseBuildComponentTypes(imageTags);
|
|
||||||
const values = [
|
const values = [
|
||||||
...Object.entries(imageTags?.images ?? {})
|
...Object.values(imageTags?.images ?? {}),
|
||||||
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes))
|
...Object.values(imageTags?.artifacts ?? {})
|
||||||
.map(([, value]) => value),
|
|
||||||
...Object.entries(imageTags?.artifacts ?? {})
|
|
||||||
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes))
|
|
||||||
.map(([, value]) => value)
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (values.length) return values;
|
if (values.length) return values;
|
||||||
@@ -141,32 +114,9 @@ function newestBuildVersion(builds: BuildJob[]) {
|
|||||||
.find((value): value is string => Boolean(value)) ?? '';
|
.find((value): value is string => Boolean(value)) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementPatchVersion(value: string) {
|
|
||||||
const match = value.trim().match(/^(v?)(\d+)\.(\d+)\.(\d+)(.*)$/);
|
|
||||||
if (!match) return value;
|
|
||||||
const [, prefix, major, minor, patch, suffix] = match;
|
|
||||||
return `${prefix}${major}.${minor}.${Number(patch) + 1}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newestReleaseVersion(releases: AppRelease[], appId: string) {
|
|
||||||
return releases
|
|
||||||
.filter((release) => release.app.id === appId && release.version)
|
|
||||||
.sort((a, b) => releaseTime(b) - releaseTime(a))
|
|
||||||
.map((release) => release.version)
|
|
||||||
.find((value): value is string => Boolean(value)) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextReleaseVersion(releases: AppRelease[], appId: string, builds: BuildJob[]) {
|
|
||||||
const latestReleaseVersion = newestReleaseVersion(releases, appId);
|
|
||||||
if (latestReleaseVersion) return incrementPatchVersion(latestReleaseVersion);
|
|
||||||
return newestBuildVersion(builds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCount(release: AppRelease) {
|
function buildCount(release: AppRelease) {
|
||||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||||
const componentTypes = releaseBuildComponentTypes(imageTags);
|
const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
|
||||||
const count = Object.entries(imageTags?.images ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length
|
|
||||||
+ Object.entries(imageTags?.artifacts ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length;
|
|
||||||
return count || (release.artifact ? 1 : 0);
|
return count || (release.artifact ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +266,7 @@ export function ReleaseManagementPage() {
|
|||||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
|
||||||
const latestBuilds = latestCandidateBuilds(appBuilds);
|
const latestBuilds = latestCandidateBuilds(appBuilds);
|
||||||
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
|
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
|
||||||
setVersion(nextReleaseVersion(releases, nextAppId, appBuilds));
|
setVersion((prev) => prev || newestBuildVersion(appBuilds));
|
||||||
setReleaseModalOpen(true);
|
setReleaseModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -426,7 +376,6 @@ export function ReleaseManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<Requester item={release} />
|
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
||||||
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
||||||
@@ -507,27 +456,19 @@ export function ReleaseManagementPage() {
|
|||||||
|
|
||||||
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block">
|
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 <b className="text-foreground">*</b></span>
|
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
|
|
||||||
selectApp(event.target.value);
|
selectApp(event.target.value);
|
||||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
||||||
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
||||||
setVersion(nextReleaseVersion(releases, event.target.value, appBuilds));
|
setVersion(newestBuildVersion(appBuilds));
|
||||||
}}>
|
}}>
|
||||||
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
|
|
||||||
</label>
|
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||||
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">빌드 선택 <b className="text-foreground">*</b></p>
|
|
||||||
<div className="mt-2 max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
|
||||||
{modalBuilds.length === 0 ? (
|
{modalBuilds.length === 0 ? (
|
||||||
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||||
등록 가능한 새 빌드 결과가 없습니다.
|
등록 가능한 새 빌드 결과가 없습니다.
|
||||||
@@ -553,7 +494,6 @@ export function ReleaseManagementPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(selectedReleaseLog)}
|
open={Boolean(selectedReleaseLog)}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export function RepositoryManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">저장소명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||||
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
||||||
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
||||||
{giteaAppOrg}/
|
{giteaAppOrg}/
|
||||||
@@ -389,6 +389,7 @@ export function RepositoryManagementPage() {
|
|||||||
<input
|
<input
|
||||||
value={repoName}
|
value={repoName}
|
||||||
onChange={(event) => handleRepoNameChange(event.target.value)}
|
onChange={(event) => handleRepoNameChange(event.target.value)}
|
||||||
|
placeholder="lms-fe"
|
||||||
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
|
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Copy, FileText, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { CommitAuthor } from '../../components/commit-author';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
@@ -63,19 +62,11 @@ function splitRepository(repository?: string | null) {
|
|||||||
return owner && repo ? { owner, repo } : null;
|
return owner && repo ? { owner, repo } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpCloneUrl(repository?: string | null) {
|
function repositoryGitUrl(repository?: string | null) {
|
||||||
const normalized = normalizeRepository(repository);
|
const normalized = normalizeRepository(repository);
|
||||||
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
|
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function sshCloneUrl(repository?: string | null) {
|
|
||||||
const normalized = normalizeRepository(repository);
|
|
||||||
if (!normalized) return '-';
|
|
||||||
const base = giteaBaseUrl.replace(/\/$/, '');
|
|
||||||
const host = base.replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || 'localhost';
|
|
||||||
return `ssh://git@${host}:2222/${normalized}.git`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitAuthor(commit: GiteaCommit) {
|
function commitAuthor(commit: GiteaCommit) {
|
||||||
return commit.author?.login
|
return commit.author?.login
|
||||||
|| commit.author?.username
|
|| commit.author?.username
|
||||||
@@ -330,13 +321,13 @@ export function SourceManagementPage() {
|
|||||||
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
|
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
|
||||||
const repository = splitRepository(selectedSource?.repository);
|
const repository = splitRepository(selectedSource?.repository);
|
||||||
const branch = selectedBranch || 'develop';
|
const branch = selectedBranch || 'develop';
|
||||||
|
const latestBranchSha = commits.length > 0 ? commits[0]?.sha ?? '' : '';
|
||||||
const branchOptions = useMemo(
|
const branchOptions = useMemo(
|
||||||
() => [...new Set(['develop', 'release', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
|
() => [...new Set(['develop', 'release', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
|
||||||
[selectedSource?.branch]
|
[selectedSource?.branch]
|
||||||
);
|
);
|
||||||
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
||||||
const selectedHttpCloneUrl = httpCloneUrl(selectedSource?.repository);
|
const selectedRepositoryGitUrl = repositoryGitUrl(selectedSource?.repository);
|
||||||
const selectedSshCloneUrl = sshCloneUrl(selectedSource?.repository);
|
|
||||||
|
|
||||||
const refreshSources = () => {
|
const refreshSources = () => {
|
||||||
setSourcesLoading(true);
|
setSourcesLoading(true);
|
||||||
@@ -435,11 +426,11 @@ export function SourceManagementPage() {
|
|||||||
.finally(() => setDiffLoading(false));
|
.finally(() => setDiffLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyCloneUrl = (value: string, label: string) => {
|
const copyGitUrl = () => {
|
||||||
if (!selectedSource?.repository || value === '-') return;
|
if (!selectedSource?.repository || selectedRepositoryGitUrl === '-') return;
|
||||||
navigator.clipboard?.writeText(value)
|
navigator.clipboard?.writeText(selectedRepositoryGitUrl)
|
||||||
.then(() => toast({ type: 'success', message: `${label}을 복사했습니다.` }))
|
.then(() => toast({ type: 'success', message: 'Git URL을 복사했습니다.' }))
|
||||||
.catch(() => toast({ type: 'error', message: `${label} 복사에 실패했습니다.` }));
|
.catch(() => toast({ type: 'error', message: 'Git URL 복사에 실패했습니다.' }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -481,7 +472,7 @@ export function SourceManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3 text-sm">
|
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-sm md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||||
<select
|
<select
|
||||||
@@ -494,28 +485,27 @@ export function SourceManagementPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 md:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">저장소</p>
|
<p className="text-xs text-muted-foreground">저장소</p>
|
||||||
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
|
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 md:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
<p className="text-xs text-muted-foreground">Git URL</p>
|
||||||
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedHttpCloneUrl}>{selectedHttpCloneUrl}</p>
|
<p className="min-w-0 flex-1 truncate text-sm font-medium" title={selectedRepositoryGitUrl}>{selectedRepositoryGitUrl}</p>
|
||||||
<Button variant="ghost" className="size-8 shrink-0 px-0" title="HTTP Clone URL 복사" aria-label="HTTP Clone URL 복사" onClick={() => copyCloneUrl(selectedHttpCloneUrl, 'HTTP Clone URL')} disabled={!selectedSource?.repository}>
|
<Button variant="ghost" className="size-8 shrink-0 px-0" title="Git URL 복사" aria-label="Git URL 복사" onClick={copyGitUrl} disabled={!selectedSource?.repository}>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 md:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">SSH Clone URL</p>
|
<p className="text-xs text-muted-foreground">SHA1</p>
|
||||||
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedSshCloneUrl}>{selectedSshCloneUrl}</p>
|
<p className="truncate font-mono text-sm font-medium" title={latestBranchSha || '-'}>
|
||||||
<Button variant="ghost" className="size-8 shrink-0 px-0" title="SSH Clone URL 복사" aria-label="SSH Clone URL 복사" onClick={() => copyCloneUrl(selectedSshCloneUrl, 'SSH Clone URL')} disabled={!selectedSource?.repository}>
|
{latestBranchSha || '-'}
|
||||||
<Copy className="size-4" />
|
</p>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,7 +549,7 @@ export function SourceManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
@@ -574,7 +564,7 @@ export function SourceManagementPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ import type {
|
|||||||
} from '../../types/application';
|
} from '../../types/application';
|
||||||
import type { ApiAppComponent } from '../../types/runtime';
|
import type { ApiAppComponent } from '../../types/runtime';
|
||||||
|
|
||||||
export type DeploymentResourceLimits = {
|
|
||||||
replicas?: number;
|
|
||||||
cpuRequest?: string;
|
|
||||||
cpuLimit?: string;
|
|
||||||
memoryRequest?: string;
|
|
||||||
memoryLimit?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GiteaCommit = {
|
export type GiteaCommit = {
|
||||||
sha: string;
|
sha: string;
|
||||||
commit?: {
|
commit?: {
|
||||||
@@ -38,16 +30,12 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
@@ -88,7 +76,7 @@ export function createComponent(
|
|||||||
buildTarget?: string;
|
buildTarget?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; nodePort?: number; protocol?: string }>;
|
ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||||
@@ -238,24 +226,7 @@ export function listDeploymentsPage(options: { appKey?: string; page: number; li
|
|||||||
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDeployment(input: {
|
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
||||||
releaseId: string;
|
|
||||||
environment: string;
|
|
||||||
namespace: string;
|
|
||||||
updateLauncherAppVersion?: boolean;
|
|
||||||
resources?: DeploymentResourceLimits;
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(input)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createQuickDeployment(input: {
|
|
||||||
buildJobId: string;
|
|
||||||
environment: string;
|
|
||||||
namespace: string;
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export function CommonAppBuildPage() {
|
|||||||
[pipelines]
|
[pipelines]
|
||||||
);
|
);
|
||||||
const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0];
|
const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0];
|
||||||
const effectiveHistoryTotalPages = historyTotal !== null
|
|
||||||
? Math.max(1, historyTotalPages ?? Math.ceil(historyTotal / historyPageSize))
|
|
||||||
: historyTotalPages;
|
|
||||||
|
|
||||||
const refreshHistory = useCallback((repoId: string, page: number, silent = false) => {
|
const refreshHistory = useCallback((repoId: string, page: number, silent = false) => {
|
||||||
if (!repoId) {
|
if (!repoId) {
|
||||||
@@ -212,8 +209,8 @@ export function CommonAppBuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
<div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{historyTotal !== null
|
{historyTotal !== null && historyTotalPages !== null
|
||||||
? `총 ${historyTotal}건 · ${historyPage} / ${effectiveHistoryTotalPages ?? 1}`
|
? `총 ${historyTotal}건 · ${historyPage} / ${historyTotalPages}`
|
||||||
: `현재 ${historyPage}페이지`}
|
: `현재 ${historyPage}페이지`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
@@ -8,9 +8,7 @@ import type { RuntimeResource } from '../../types/runtime';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
loadLauncherRuntimeResources,
|
loadLauncherRuntimeResources,
|
||||||
loadLauncherComponentLogs,
|
|
||||||
loadRuntimeWorkloadEnv,
|
loadRuntimeWorkloadEnv,
|
||||||
restartLauncherComponent,
|
|
||||||
saveRuntimeWorkloadEnv
|
saveRuntimeWorkloadEnv
|
||||||
} from '../launcher/launcher-api';
|
} from '../launcher/launcher-api';
|
||||||
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||||
@@ -26,10 +24,6 @@ function apiKind(kind: string) {
|
|||||||
return kind.toLowerCase();
|
return kind.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripAnsi(value: string) {
|
|
||||||
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
||||||
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
||||||
}
|
}
|
||||||
@@ -59,17 +53,10 @@ export function CommonAppComponentsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logTarget, setLogTarget] = useState<CommonAppComponent | null>(null);
|
|
||||||
const [logs, setLogs] = useState('');
|
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
|
||||||
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
||||||
const [restartTarget, setRestartTarget] = useState<CommonAppComponent | null>(null);
|
|
||||||
const [restartKey, setRestartKey] = useState('');
|
|
||||||
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const loadingVisible = useMinimumLoading(loading || envLoading);
|
const loadingVisible = useMinimumLoading(loading || envLoading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -91,25 +78,13 @@ export function CommonAppComponentsPage() {
|
|||||||
setEnvVariables([]);
|
setEnvVariables([]);
|
||||||
setEnvLoading(true);
|
setEnvLoading(true);
|
||||||
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
.then((result) => {
|
.then((result) => setEnvVariables(result.variables ?? []))
|
||||||
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
})
|
})
|
||||||
.finally(() => setEnvLoading(false));
|
.finally(() => setEnvLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLogs = (component: CommonAppComponent) => {
|
|
||||||
setLogTarget(component);
|
|
||||||
setLogs('');
|
|
||||||
setLogLoading(true);
|
|
||||||
loadLauncherComponentLogs(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
|
||||||
.then((result) => setLogs(stripAnsi(result.logs || '로그가 없습니다.')))
|
|
||||||
.catch((error: Error) => setLogs(`[warn] 로그 조회 실패\n${error.message}`))
|
|
||||||
.finally(() => setLogLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
||||||
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -120,27 +95,6 @@ export function CommonAppComponentsPage() {
|
|||||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVariable = () => {
|
|
||||||
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
|
||||||
newEnvKeyInputRef.current?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const restart = (component: CommonAppComponent) => {
|
|
||||||
setRestartKey(component.key);
|
|
||||||
restartLauncherComponent(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: `${component.name} 재시작을 요청했습니다.` });
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '재시작 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setRestartKey(''));
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEnv = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget) return;
|
if (!envTarget) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -192,7 +146,7 @@ export function CommonAppComponentsPage() {
|
|||||||
description={component.description}
|
description={component.description}
|
||||||
action={<Badge tone={statusTone(status)}>{status}</Badge>}
|
action={<Badge tone={statusTone(status)}>{status}</Badge>}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]">
|
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
@@ -219,15 +173,7 @@ export function CommonAppComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4">
|
||||||
<Button variant="outline" onClick={() => openLogs(component)} disabled={!workload}>
|
|
||||||
<TerminalSquare className="size-4" />
|
|
||||||
로그
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setRestartTarget(component)} disabled={!workload || restartKey === component.key}>
|
|
||||||
<RotateCcw className={cn('size-4', restartKey === component.key && 'animate-spin')} />
|
|
||||||
재시작
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
설정값
|
설정값
|
||||||
@@ -272,59 +218,13 @@ export function CommonAppComponentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(logTarget)}
|
|
||||||
title={logTarget ? `${logTarget.name} 로그` : '로그'}
|
|
||||||
onClose={() => setLogTarget(null)}
|
|
||||||
className="!max-w-5xl"
|
|
||||||
>
|
|
||||||
<pre className="max-h-[62vh] overflow-auto rounded-md bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
|
||||||
{logs || (logLoading ? '로그를 불러오는 중...' : '로그가 없습니다.')}
|
|
||||||
</pre>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(restartTarget)}
|
|
||||||
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
|
||||||
onClose={() => {
|
|
||||||
if (!restartTarget || restartKey !== restartTarget.key) setRestartTarget(null);
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && restartKey === restartTarget.key)}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (!restartTarget) return;
|
|
||||||
restart(restartTarget);
|
|
||||||
setRestartTarget(null);
|
|
||||||
}}
|
|
||||||
disabled={Boolean(restartTarget && restartKey === restartTarget.key)}
|
|
||||||
>
|
|
||||||
재시작
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
onClose={() => setEnvTarget(null)}
|
onClose={() => setEnvTarget(null)}
|
||||||
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
className="!max-w-4xl"
|
||||||
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
설정값 추가
|
|
||||||
</Button>
|
|
||||||
<span className="flex-1" />
|
|
||||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
{envSaving ? '저장 중' : '저장'}
|
{envSaving ? '저장 중' : '저장'}
|
||||||
@@ -332,8 +232,8 @@ export function CommonAppComponentsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="space-y-4">
|
||||||
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
<div className="rounded-md border border-border">
|
||||||
<table className="w-full table-fixed text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[26%]" />
|
<col className="w-[26%]" />
|
||||||
@@ -366,7 +266,6 @@ export function CommonAppComponentsPage() {
|
|||||||
<tr key={`${variable.key}-${index}`}>
|
<tr key={`${variable.key}-${index}`}>
|
||||||
<td className="min-w-0 px-3 py-2.5">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
|
||||||
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.key}
|
value={variable.key}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
@@ -413,6 +312,14 @@ export function CommonAppComponentsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }])}
|
||||||
|
disabled={envLoading}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -24,25 +24,6 @@ function eventTone(type?: string) {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventRanges = [
|
|
||||||
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
|
||||||
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
|
||||||
{ label: '전체', value: 'all', milliseconds: null }
|
|
||||||
];
|
|
||||||
|
|
||||||
function eventTime(event: RuntimeEvent) {
|
|
||||||
const value = event.lastTimestamp ?? event.firstTimestamp;
|
|
||||||
if (!value) return 0;
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) ? time : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
|
||||||
if (!range.milliseconds) return true;
|
|
||||||
const time = eventTime(event);
|
|
||||||
return time > 0 && time >= Date.now() - range.milliseconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricKey(namespace: string, pod: string) {
|
function metricKey(namespace: string, pod: string) {
|
||||||
return `${namespace}/${pod}`;
|
return `${namespace}/${pod}`;
|
||||||
}
|
}
|
||||||
@@ -144,7 +125,6 @@ export function CommonAppLogsPage() {
|
|||||||
const [tail, setTail] = useState(100);
|
const [tail, setTail] = useState(100);
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [eventRange, setEventRange] = useState('1h');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -162,9 +142,7 @@ export function CommonAppLogsPage() {
|
|||||||
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
||||||
const loggableResources = componentResources.filter(isActiveLogTarget);
|
const loggableResources = componentResources.filter(isActiveLogTarget);
|
||||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
|
||||||
const filteredEvents = events.filter((event) => {
|
const filteredEvents = events.filter((event) => {
|
||||||
if (!isEventInRange(event, selectedEventRange)) return false;
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
const componentMatch = selectedComponent
|
const componentMatch = selectedComponent
|
||||||
? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase())
|
? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase())
|
||||||
@@ -219,26 +197,6 @@ export function CommonAppLogsPage() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== 'events') return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const loadEvents = () => {
|
|
||||||
loadRuntimeEvents(COMMON_APP_NAMESPACE)
|
|
||||||
.then((nextEvents) => {
|
|
||||||
if (!cancelled) setEvents(nextEvents);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEvents();
|
|
||||||
const timer = window.setInterval(loadEvents, 10_000);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -371,19 +329,6 @@ export function CommonAppLogsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{eventRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.value}
|
|
||||||
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
|
||||||
className="h-8 px-3 text-[13px]"
|
|
||||||
onClick={() => setEventRange(range.value)}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full lg:w-80">
|
<div className="relative w-full lg:w-80">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -393,7 +338,6 @@ export function CommonAppLogsPage() {
|
|||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { FileText, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { CommitAuthor } from '../../components/commit-author';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { GiteaRepo } from '../../types/application';
|
import type { GiteaRepo } from '../../types/application';
|
||||||
@@ -41,6 +40,11 @@ function splitRepository(repository: string) {
|
|||||||
return owner && repo ? { owner, repo } : null;
|
return owner && repo ? { owner, repo } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cicdLabel(pipeline?: CommonAppPipeline) {
|
||||||
|
if (!pipeline) return '-';
|
||||||
|
return pipeline.ciUrl ? `Woodpecker / ${pipeline.ciUrl}` : 'Woodpecker';
|
||||||
|
}
|
||||||
|
|
||||||
function commitAuthor(commit: GiteaCommit) {
|
function commitAuthor(commit: GiteaCommit) {
|
||||||
return commit.author?.login
|
return commit.author?.login
|
||||||
|| commit.author?.username
|
|| commit.author?.username
|
||||||
@@ -278,20 +282,8 @@ export function CommonAppSourcePage() {
|
|||||||
<p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p>
|
<p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-xs text-muted-foreground">CI/CD</p>
|
<p className="text-xs text-muted-foreground">CI/CD</p>
|
||||||
<Badge tone={selectedPipeline ? 'green' : 'neutral'}>{selectedPipeline ? '연결됨' : '미연결'}</Badge>
|
<p className="mt-2 truncate text-sm font-semibold">{cicdLabel(selectedPipeline)}</p>
|
||||||
</div>
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="text-muted-foreground">도구</span>
|
|
||||||
<span className="font-semibold text-foreground">{selectedPipeline ? 'Woodpecker CI' : '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="text-muted-foreground">서버</span>
|
|
||||||
<span className="min-w-0 truncate font-mono text-xs font-medium text-foreground">{selectedPipeline?.ciUrl || '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||||
@@ -363,7 +355,7 @@ export function CommonAppSourcePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
@@ -378,7 +370,7 @@ export function CommonAppSourcePage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
import { FileCode2, RotateCcw } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
|
||||||
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
|
||||||
import { cn } from '../../lib/utils';
|
|
||||||
import type { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
|
||||||
import { loadApps, loadRuntimePodMetrics, loadRuntimeResourceYaml, loadRuntimeResources } from '../runtime/runtime-api';
|
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
||||||
|
|
||||||
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
|
||||||
|
|
||||||
type NamespaceRuntime = RuntimeResourceResponse & {
|
|
||||||
failed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type InfrastructureData = {
|
|
||||||
namespaces: string[];
|
|
||||||
runtimes: NamespaceRuntime[];
|
|
||||||
metrics: RuntimePodMetric[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResourceRow = RuntimeResource & {
|
|
||||||
id: string;
|
|
||||||
namespace: string;
|
|
||||||
metric?: RuntimePodMetric;
|
|
||||||
};
|
|
||||||
|
|
||||||
function uniqueNamespaces(values: Array<string | null | undefined>) {
|
|
||||||
return [...new Set(values.filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorkload(resource: RuntimeResource) {
|
|
||||||
return ['Deployment', 'StatefulSet', 'DaemonSet', 'Pod', 'Job', 'CronJob', 'ReplicaSet'].includes(resource.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNetwork(resource: RuntimeResource) {
|
|
||||||
return ['Service', 'Ingress', 'Endpoint', 'Endpoints'].includes(resource.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStorage(resource: RuntimeResource) {
|
|
||||||
return ['PersistentVolumeClaim', 'PVC', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
|
||||||
const normalized = status.toLowerCase();
|
|
||||||
if (['running', 'ready', 'active', 'bound'].some((word) => normalized.includes(word))) return 'green';
|
|
||||||
if (['pending', 'progressing', 'containercreating'].some((word) => normalized.includes(word))) return 'amber';
|
|
||||||
if (['failed', 'error', 'crash', 'unhealthy', 'notready'].some((word) => normalized.includes(word))) return 'red';
|
|
||||||
if (['completed', 'succeeded'].some((word) => normalized.includes(word))) return 'neutral';
|
|
||||||
return 'blue';
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricFor(resource: ResourceRow, metrics: RuntimePodMetric[]) {
|
|
||||||
if (resource.kind !== 'Pod') return undefined;
|
|
||||||
return metrics.find((metric) => metric.namespace === resource.namespace && metric.pod === resource.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useInfrastructureData() {
|
|
||||||
const [data, setData] = useState<InfrastructureData>({ namespaces: defaultNamespaces, runtimes: [], metrics: [] });
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
loadApps().catch(() => []),
|
|
||||||
loadRuntimePodMetrics().catch(() => [] as RuntimePodMetric[])
|
|
||||||
])
|
|
||||||
.then(async ([apps, metrics]) => {
|
|
||||||
const namespaces = uniqueNamespaces([...defaultNamespaces, ...apps.map((app) => app.namespace)]);
|
|
||||||
const runtimes = await Promise.all(
|
|
||||||
namespaces.map((namespace) => (
|
|
||||||
loadRuntimeResources(namespace)
|
|
||||||
.then((runtime) => runtime)
|
|
||||||
.catch(() => ({ namespace, exists: false, failed: true, summary: { pods: 0, deployments: 0, statefulSets: 0, services: 0, persistentVolumeClaims: 0 }, resources: [] }))
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
setData({ namespaces, runtimes, metrics });
|
|
||||||
setFailed(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setFailed(true);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
return { data, loading, loadingVisible, failed, refresh };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowsFromData(data: InfrastructureData, predicate: (resource: RuntimeResource) => boolean) {
|
|
||||||
return data.runtimes.flatMap((runtime) => (
|
|
||||||
runtime.resources
|
|
||||||
.filter(predicate)
|
|
||||||
.map((resource) => {
|
|
||||||
const row = {
|
|
||||||
...resource,
|
|
||||||
namespace: runtime.namespace,
|
|
||||||
id: `${runtime.namespace}:${resource.kind}:${resource.name}`
|
|
||||||
};
|
|
||||||
return { ...row, metric: metricFor(row, data.metrics) };
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceStatus({ status }: { status: string }) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(status))} />
|
|
||||||
<Badge tone={resourceStatusTone(status)}>{status || '-'}</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NamespaceFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
|
||||||
return {
|
|
||||||
key: 'namespace',
|
|
||||||
label: '네임스페이스',
|
|
||||||
options: uniqueNamespaces(rows.map((row) => row.namespace)).map((namespace) => ({ label: namespace, value: namespace })),
|
|
||||||
getValue: (row) => row.namespace
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function KindFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
|
||||||
return {
|
|
||||||
key: 'kind',
|
|
||||||
label: '종류',
|
|
||||||
options: uniqueNamespaces(rows.map((row) => row.kind)).map((kind) => ({ label: kind, value: kind })),
|
|
||||||
getValue: (row) => row.kind
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfraPageShell({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
loading,
|
|
||||||
loadingVisible,
|
|
||||||
failed,
|
|
||||||
onRefresh
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children: ReactNode;
|
|
||||||
loading: boolean;
|
|
||||||
loadingVisible: boolean;
|
|
||||||
failed: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
eyebrow="인프라"
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={onRefresh} disabled={loading}>
|
|
||||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{failed ? (
|
|
||||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
|
||||||
인프라 데이터를 불러오지 못했습니다.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
<LoadingModal open={loadingVisible} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceTable({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
rows,
|
|
||||||
loading,
|
|
||||||
emptyMessage
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
rows: ResourceRow[];
|
|
||||||
loading: boolean;
|
|
||||||
emptyMessage: string;
|
|
||||||
}) {
|
|
||||||
const [yamlTarget, setYamlTarget] = useState<ResourceRow | null>(null);
|
|
||||||
const [yaml, setYaml] = useState('');
|
|
||||||
const [yamlLoading, setYamlLoading] = useState(false);
|
|
||||||
const [yamlError, setYamlError] = useState('');
|
|
||||||
|
|
||||||
const openYaml = (row: ResourceRow) => {
|
|
||||||
setYamlTarget(row);
|
|
||||||
setYaml('');
|
|
||||||
setYamlError('');
|
|
||||||
setYamlLoading(true);
|
|
||||||
loadRuntimeResourceYaml(row.namespace, row.kind, row.name)
|
|
||||||
.then((response) => setYaml(response.yaml))
|
|
||||||
.catch((error: unknown) => setYamlError(error instanceof Error ? error.message : 'YAML을 불러오지 못했습니다.'))
|
|
||||||
.finally(() => setYamlLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
|
||||||
() => [
|
|
||||||
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
|
||||||
{ key: 'kind', header: '종류', className: 'w-40 px-5 py-3', cell: (row) => <Badge tone="neutral">{row.kind}</Badge> },
|
|
||||||
{ key: 'name', header: '이름', className: 'min-w-[240px] px-5 py-3', cell: (row) => <span className="font-medium">{row.name}</span> },
|
|
||||||
{ key: 'status', header: '상태', className: 'w-44 px-5 py-3', cell: (row) => <ResourceStatus status={row.status} /> },
|
|
||||||
{ key: 'ready', header: 'Ready', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.ready || '-'}</span> },
|
|
||||||
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
|
||||||
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
|
||||||
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
|
||||||
{ key: 'detail', header: '상세', className: 'min-w-[220px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> },
|
|
||||||
{
|
|
||||||
key: 'yaml',
|
|
||||||
header: '',
|
|
||||||
className: 'w-14 px-3 py-3 text-right',
|
|
||||||
cell: (row) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 px-0"
|
|
||||||
title="YAML 보기"
|
|
||||||
aria-label="YAML 보기"
|
|
||||||
onClick={() => openYaml(row)}
|
|
||||||
>
|
|
||||||
<FileCode2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTableCard
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
filters={filters}
|
|
||||||
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
|
||||||
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
|
||||||
getRowKey={(row) => row.id}
|
|
||||||
pageSize={10}
|
|
||||||
minWidth={1320}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
open={Boolean(yamlTarget)}
|
|
||||||
title={yamlTarget ? `${yamlTarget.kind}/${yamlTarget.name}` : 'YAML'}
|
|
||||||
onClose={() => setYamlTarget(null)}
|
|
||||||
className="!max-w-5xl"
|
|
||||||
>
|
|
||||||
{yamlLoading ? <p className="py-10 text-center text-sm text-muted-foreground">불러오는 중...</p> : null}
|
|
||||||
{yamlError ? <p className="py-4 text-sm text-rose-300">{yamlError}</p> : null}
|
|
||||||
{!yamlLoading && !yamlError ? (
|
|
||||||
<pre className="max-h-[65vh] overflow-auto rounded-md border border-border bg-black/30 p-4 text-xs leading-6 text-slate-200">{yaml}</pre>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InfrastructureClusterPage() {
|
|
||||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
|
||||||
|
|
||||||
const totals = useMemo(() => data.runtimes.reduce(
|
|
||||||
(acc, runtime) => ({
|
|
||||||
namespaces: acc.namespaces + 1,
|
|
||||||
resources: acc.resources + runtime.resources.length,
|
|
||||||
pods: acc.pods + runtime.summary.pods,
|
|
||||||
deployments: acc.deployments + runtime.summary.deployments,
|
|
||||||
services: acc.services + runtime.summary.services,
|
|
||||||
pvc: acc.pvc + runtime.summary.persistentVolumeClaims,
|
|
||||||
failed: acc.failed + (runtime.failed || !runtime.exists ? 1 : 0)
|
|
||||||
}),
|
|
||||||
{ namespaces: 0, resources: 0, pods: 0, deployments: 0, services: 0, pvc: 0, failed: 0 }
|
|
||||||
), [data.runtimes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InfraPageShell title="클러스터" description="네임스페이스별 런타임 리소스 요약을 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
|
||||||
{[
|
|
||||||
['네임스페이스', totals.namespaces],
|
|
||||||
['전체 리소스', totals.resources],
|
|
||||||
['Pod', totals.pods],
|
|
||||||
['Deployment', totals.deployments],
|
|
||||||
['Service', totals.services],
|
|
||||||
['PVC', totals.pvc]
|
|
||||||
].map(([label, value]) => (
|
|
||||||
<Card key={label} className="px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-foreground">{value}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mt-4 overflow-hidden">
|
|
||||||
<CardHeader title="네임스페이스" description="조회 가능한 네임스페이스의 리소스 구성입니다." />
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{data.runtimes.map((runtime) => (
|
|
||||||
<div key={runtime.namespace} className="grid gap-3 px-5 py-4 text-sm md:grid-cols-[minmax(180px,1fr)_repeat(5,minmax(80px,auto))] md:items-center">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn('size-2.5 rounded-full', runtime.failed || !runtime.exists ? 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]' : 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]')} />
|
|
||||||
<p className="truncate font-semibold">{runtime.namespace}</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{runtime.failed ? '조회 실패' : runtime.exists ? '조회 가능' : '네임스페이스 없음'}</p>
|
|
||||||
</div>
|
|
||||||
<SummaryValue label="Pod" value={runtime.summary.pods} />
|
|
||||||
<SummaryValue label="Deployment" value={runtime.summary.deployments} />
|
|
||||||
<SummaryValue label="StatefulSet" value={runtime.summary.statefulSets} />
|
|
||||||
<SummaryValue label="Service" value={runtime.summary.services} />
|
|
||||||
<SummaryValue label="PVC" value={runtime.summary.persistentVolumeClaims} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</InfraPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryValue({ label, value }: { label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-1 font-semibold text-foreground">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InfrastructureWorkloadsPage() {
|
|
||||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
|
||||||
const rows = useMemo(() => rowsFromData(data, isWorkload), [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InfraPageShell title="워크로드" description="Deployment, StatefulSet, Pod 등 실행 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
|
||||||
<ResourceTable title="워크로드 목록" description="실행 상태, Ready, 재시작 횟수와 Pod 메트릭을 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 워크로드가 없습니다." />
|
|
||||||
</InfraPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InfrastructureNetworkPage() {
|
|
||||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
|
||||||
const rows = useMemo(() => rowsFromData(data, isNetwork), [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InfraPageShell title="네트워크" description="Service, Ingress 등 네트워크 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
|
||||||
<ResourceTable title="네트워크 리소스" description="서비스와 인그레스 연결 정보를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 네트워크 리소스가 없습니다." />
|
|
||||||
</InfraPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InfrastructureStoragePage() {
|
|
||||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
|
||||||
const rows = useMemo(() => rowsFromData(data, isStorage), [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InfraPageShell title="스토리지" description="PVC, PV, StorageClass 등 저장소 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
|
||||||
<ResourceTable title="스토리지 리소스" description="영구 볼륨과 스토리지 연결 상태를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 스토리지 리소스가 없습니다." />
|
|
||||||
</InfraPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,19 +30,6 @@ type DisplayForm = {
|
|||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconOptions = [
|
|
||||||
{ value: 'code', label: 'Code' },
|
|
||||||
{ value: 'bar-chart', label: 'Bar chart' },
|
|
||||||
{ value: 'monitor', label: 'Monitor' },
|
|
||||||
{ value: 'truck', label: 'Truck' },
|
|
||||||
{ value: 'graduation', label: 'Graduation' },
|
|
||||||
{ value: 'activity', label: 'Activity' },
|
|
||||||
{ value: 'sliders', label: 'Sliders' },
|
|
||||||
{ value: 'shopping-cart', label: 'Shopping cart' },
|
|
||||||
{ value: 'map', label: 'Map' },
|
|
||||||
];
|
|
||||||
const showDevelopmentFields = import.meta.env.DEV;
|
|
||||||
|
|
||||||
function releaseLabel(link: LauncherAppLink) {
|
function releaseLabel(link: LauncherAppLink) {
|
||||||
return link.release?.version ?? '-';
|
return link.release?.version ?? '-';
|
||||||
}
|
}
|
||||||
@@ -314,12 +301,11 @@ export function LauncherAppsPage() {
|
|||||||
>
|
>
|
||||||
{displayForm ? (
|
{displayForm ? (
|
||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">표시명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">표시명</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
<div className="grid gap-4 sm:grid-cols-[1fr_120px]">
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
||||||
<input type="number" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
<input type="number" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
||||||
@@ -332,25 +318,29 @@ export function LauncherAppsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">보조 문구</span>
|
<span className="text-xs font-medium text-muted-foreground">보조 문구</span>
|
||||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.iconKey} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block w-full">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">자세히 보기 URL 링크</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
|
||||||
</label>
|
|
||||||
{showDevelopmentFields ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">실행 URL</span>
|
<span className="text-xs font-medium text-muted-foreground">실행 URL</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">상세 URL</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
|
<span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
||||||
@@ -360,21 +350,10 @@ export function LauncherAppsPage() {
|
|||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span>
|
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span>
|
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={iconOptions.some((option) => option.value === displayForm.iconKey) ? displayForm.iconKey : 'code'} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)}>
|
|
||||||
{iconOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -7,8 +7,6 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
createLauncherBuildJob,
|
createLauncherBuildJob,
|
||||||
deployLauncherBackend,
|
|
||||||
getLauncherBackendDeployment,
|
|
||||||
getLauncherSource,
|
getLauncherSource,
|
||||||
listLauncherBuildEnv,
|
listLauncherBuildEnv,
|
||||||
listLauncherBuildJobs,
|
listLauncherBuildJobs,
|
||||||
@@ -18,7 +16,6 @@ import {
|
|||||||
type LauncherSource,
|
type LauncherSource,
|
||||||
type LauncherBuildJob,
|
type LauncherBuildJob,
|
||||||
type LauncherBuildEnvVariable,
|
type LauncherBuildEnvVariable,
|
||||||
type LauncherBackendDeployment,
|
|
||||||
type LauncherVersion
|
type LauncherVersion
|
||||||
} from './launcher-api';
|
} from './launcher-api';
|
||||||
|
|
||||||
@@ -51,37 +48,6 @@ function jobTime(job: LauncherBuildJob) {
|
|||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BuildWorker({ job }: { job: LauncherBuildJob }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = job.requestedByName
|
|
||||||
|| job.createdByName
|
|
||||||
|| job.requestedBy
|
|
||||||
|| job.createdBy
|
|
||||||
|| '-';
|
|
||||||
const avatarUrl = job.requestedByAvatarUrl
|
|
||||||
|| job.createdByAvatarUrl
|
|
||||||
|| '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">
|
|
||||||
{name === '-' ? null : avatarUrl && !failed ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<b className="truncate leading-none text-foreground">{name}</b>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const launcherBuildJobPageSize = 3;
|
const launcherBuildJobPageSize = 3;
|
||||||
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
||||||
type BuildRange = 'week' | 'month' | 'all';
|
type BuildRange = 'week' | 'month' | 'all';
|
||||||
@@ -135,9 +101,6 @@ export function LauncherBuildPage() {
|
|||||||
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
||||||
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
||||||
const [quickDeployJob, setQuickDeployJob] = useState<LauncherBuildJob | null>(null);
|
|
||||||
const [backendDeployment, setBackendDeployment] = useState<LauncherBackendDeployment | null>(null);
|
|
||||||
const [quickDeploying, setQuickDeploying] = useState(false);
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -159,13 +122,6 @@ export function LauncherBuildPage() {
|
|||||||
const pagedJobs = jobs;
|
const pagedJobs = jobs;
|
||||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||||
|
|
||||||
const isQuickDeployable = (job: LauncherBuildJob) => (
|
|
||||||
target === 'launcher-backend'
|
|
||||||
&& job.target === 'launcher-backend'
|
|
||||||
&& job.status === 'SUCCEEDED'
|
|
||||||
&& Boolean(job.downloadUrl)
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
||||||
listLauncherBuildJobsPage({
|
listLauncherBuildJobsPage({
|
||||||
target: nextTarget,
|
target: nextTarget,
|
||||||
@@ -282,31 +238,6 @@ export function LauncherBuildPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openQuickDeploy = (job: LauncherBuildJob) => {
|
|
||||||
setQuickDeployJob(job);
|
|
||||||
setBackendDeployment(null);
|
|
||||||
getLauncherBackendDeployment()
|
|
||||||
.then(setBackendDeployment)
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '배포 대상 조회 실패', message: error.message });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const quickDeploy = () => {
|
|
||||||
if (!quickDeployJob?.downloadUrl) return;
|
|
||||||
setQuickDeploying(true);
|
|
||||||
deployLauncherBackend(quickDeployJob.downloadUrl)
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
|
||||||
setQuickDeployJob(null);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '백엔드 배포 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setQuickDeploying(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
||||||
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -474,27 +405,13 @@ export function LauncherBuildPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">작업자 <BuildWorker job={job} /></span>
|
|
||||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{isQuickDeployable(job) ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="size-8 px-0"
|
|
||||||
title="빠른 배포"
|
|
||||||
aria-label="빠른 배포"
|
|
||||||
onClick={() => openQuickDeploy(job)}
|
|
||||||
>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-muted-foreground">산출물</p>
|
<p className="text-muted-foreground">산출물</p>
|
||||||
@@ -551,51 +468,6 @@ export function LauncherBuildPage() {
|
|||||||
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
open={Boolean(quickDeployJob)}
|
|
||||||
title="빠른 배포"
|
|
||||||
onClose={() => !quickDeploying && setQuickDeployJob(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setQuickDeployJob(null)} disabled={quickDeploying}>취소</Button>
|
|
||||||
<Button onClick={quickDeploy} disabled={quickDeploying || !quickDeployJob?.downloadUrl}>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
{quickDeploying ? '배포 중' : '배포'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
|
||||||
<p className="font-medium">성공한 백엔드 빌드 이미지를 바로 배포합니다.</p>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
||||||
현재 설정된 복제 수와 CPU, 메모리 제한은 변경하지 않습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 rounded-md border border-border bg-background p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">대상</span>
|
|
||||||
<span className="font-mono text-xs font-medium">
|
|
||||||
{backendDeployment ? `${backendDeployment.namespace}/deployment/${backendDeployment.deployment}` : '배포 대상 확인 중'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">빌드</span>
|
|
||||||
<span className="font-medium">{quickDeployJob?.version || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1 pt-1">
|
|
||||||
<span className="text-muted-foreground">이미지</span>
|
|
||||||
<span className="break-all font-mono text-xs font-medium">{quickDeployJob?.downloadUrl || '-'}</span>
|
|
||||||
</div>
|
|
||||||
{backendDeployment?.currentImage ? (
|
|
||||||
<div className="grid gap-1 border-t border-border pt-3">
|
|
||||||
<span className="text-muted-foreground">현재 이미지</span>
|
|
||||||
<span className="break-all font-mono text-xs text-muted-foreground">{backendDeployment.currentImage}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="빌드 실행 확인"
|
title="빌드 실행 확인"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
@@ -64,23 +64,6 @@ function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusText(status?: string) {
|
|
||||||
if (status === 'SUCCEEDED') return '성공';
|
|
||||||
if (status === 'FAILED') return '실패';
|
|
||||||
if (status === 'RUNNING') return '진행 중';
|
|
||||||
if (status === 'PENDING') return '대기';
|
|
||||||
if (status === 'CANCELED') return '취소';
|
|
||||||
return status ?? '빌드 이력 없음';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusTone(status?: string): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
|
|
||||||
if (status === 'SUCCEEDED') return 'green';
|
|
||||||
if (status === 'FAILED' || status === 'CANCELED') return 'red';
|
|
||||||
if (status === 'RUNNING') return 'blue';
|
|
||||||
if (status === 'PENDING') return 'amber';
|
|
||||||
return 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
function componentIcon(type: LauncherComponentDefinition['type']) {
|
function componentIcon(type: LauncherComponentDefinition['type']) {
|
||||||
if (type === 'client') return FileArchive;
|
if (type === 'client') return FileArchive;
|
||||||
if (type === 'database') return Database;
|
if (type === 'database') return Database;
|
||||||
@@ -139,12 +122,9 @@ export function LauncherComponentsPage() {
|
|||||||
const [logs, setLogs] = useState('');
|
const [logs, setLogs] = useState('');
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||||
const [restartTarget, setRestartTarget] = useState<LauncherComponentDefinition | null>(null);
|
|
||||||
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||||
|
|
||||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||||
@@ -204,7 +184,7 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvLoading(true);
|
setEnvLoading(true);
|
||||||
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
setEnvVariables(result.variables ?? []);
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
@@ -222,14 +202,6 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVariable = () => {
|
|
||||||
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
|
||||||
newEnvKeyInputRef.current?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEnv = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget?.kind || !envTarget.workloadName) return;
|
if (!envTarget?.kind || !envTarget.workloadName) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -277,9 +249,7 @@ export function LauncherComponentsPage() {
|
|||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p>
|
<p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p>
|
||||||
<p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p>
|
<p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p>
|
||||||
<div className="mt-2">
|
<p className="mt-1 text-xs text-muted-foreground">{latestBuild?.status ?? '빌드 이력 없음'}</p>
|
||||||
<Badge tone={buildStatusTone(latestBuild?.status)}>{buildStatusText(latestBuild?.status)}</Badge>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">Namespace</p>
|
<p className="text-sm text-muted-foreground">Namespace</p>
|
||||||
@@ -304,7 +274,7 @@ export function LauncherComponentsPage() {
|
|||||||
description={component.description}
|
description={component.description}
|
||||||
action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>}
|
action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]">
|
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
@@ -352,7 +322,7 @@ export function LauncherComponentsPage() {
|
|||||||
<TerminalSquare className="size-4" />
|
<TerminalSquare className="size-4" />
|
||||||
로그
|
로그
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}>
|
<Button onClick={() => restart(component)} disabled={!workload || actionKey === component.key}>
|
||||||
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
||||||
재시작
|
재시작
|
||||||
</Button>
|
</Button>
|
||||||
@@ -424,48 +394,13 @@ export function LauncherComponentsPage() {
|
|||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(restartTarget)}
|
|
||||||
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
|
||||||
onClose={() => {
|
|
||||||
if (!restartTarget || actionKey !== restartTarget.key) setRestartTarget(null);
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && actionKey === restartTarget.key)}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (!restartTarget) return;
|
|
||||||
restart(restartTarget);
|
|
||||||
setRestartTarget(null);
|
|
||||||
}}
|
|
||||||
disabled={Boolean(restartTarget && actionKey === restartTarget.key)}
|
|
||||||
>
|
|
||||||
재시작
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
onClose={() => setEnvTarget(null)}
|
onClose={() => setEnvTarget(null)}
|
||||||
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
className="!max-w-4xl"
|
||||||
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
설정값 추가
|
|
||||||
</Button>
|
|
||||||
<span className="flex-1" />
|
|
||||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
{envSaving ? '저장 중' : '저장'}
|
{envSaving ? '저장 중' : '저장'}
|
||||||
@@ -473,11 +408,11 @@ export function LauncherComponentsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
||||||
</p>
|
</p>
|
||||||
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
<div className="rounded-md border border-border">
|
||||||
<table className="w-full table-fixed text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[26%]" />
|
<col className="w-[26%]" />
|
||||||
@@ -510,7 +445,6 @@ export function LauncherComponentsPage() {
|
|||||||
<tr key={`${variable.key}-${index}`}>
|
<tr key={`${variable.key}-${index}`}>
|
||||||
<td className="min-w-0 px-3 py-2.5">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
|
||||||
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.key}
|
value={variable.key}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
@@ -557,6 +491,14 @@ export function LauncherComponentsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }])}
|
||||||
|
disabled={envLoading}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -53,25 +53,6 @@ function eventTone(type?: string) {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventRanges = [
|
|
||||||
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
|
||||||
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
|
||||||
{ label: '전체', value: 'all', milliseconds: null }
|
|
||||||
];
|
|
||||||
|
|
||||||
function eventTime(event: RuntimeEvent) {
|
|
||||||
const value = event.lastTimestamp ?? event.firstTimestamp;
|
|
||||||
if (!value) return 0;
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) ? time : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
|
||||||
if (!range.milliseconds) return true;
|
|
||||||
const time = eventTime(event);
|
|
||||||
return time > 0 && time >= Date.now() - range.milliseconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricKey(namespace: string, pod: string) {
|
function metricKey(namespace: string, pod: string) {
|
||||||
return `${namespace}/${pod}`;
|
return `${namespace}/${pod}`;
|
||||||
}
|
}
|
||||||
@@ -174,7 +155,6 @@ export function LauncherLogsPage() {
|
|||||||
const [tail, setTail] = useState(100);
|
const [tail, setTail] = useState(100);
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [eventRange, setEventRange] = useState('1h');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -192,9 +172,7 @@ export function LauncherLogsPage() {
|
|||||||
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
||||||
const loggableResources = componentResources.filter(isActiveLogTarget);
|
const loggableResources = componentResources.filter(isActiveLogTarget);
|
||||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
|
||||||
const filteredEvents = events.filter((event) => {
|
const filteredEvents = events.filter((event) => {
|
||||||
if (!isEventInRange(event, selectedEventRange)) return false;
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase();
|
const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase();
|
||||||
const componentMatch = selectedComponent ? componentText.includes(selectedComponent.workloadName.toLowerCase()) : true;
|
const componentMatch = selectedComponent ? componentText.includes(selectedComponent.workloadName.toLowerCase()) : true;
|
||||||
@@ -248,26 +226,6 @@ export function LauncherLogsPage() {
|
|||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== 'events') return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const loadEvents = () => {
|
|
||||||
loadRuntimeEvents(LAUNCHER_NAMESPACE)
|
|
||||||
.then((nextEvents) => {
|
|
||||||
if (!cancelled) setEvents(nextEvents);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEvents();
|
|
||||||
const timer = window.setInterval(loadEvents, 10_000);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -400,19 +358,6 @@ export function LauncherLogsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{eventRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.value}
|
|
||||||
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
|
||||||
className="h-8 px-3 text-[13px]"
|
|
||||||
onClick={() => setEventRange(range.value)}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full lg:w-80">
|
<div className="relative w-full lg:w-80">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -422,7 +367,6 @@ export function LauncherLogsPage() {
|
|||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { CommitAuthor } from '../../components/commit-author';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { GiteaRepo } from '../../types/application';
|
import type { GiteaRepo } from '../../types/application';
|
||||||
@@ -689,7 +688,7 @@ export function LauncherSourcePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
@@ -710,7 +709,7 @@ export function LauncherSourcePage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ export function LauncherVersionsPage() {
|
|||||||
const [deployOpen, setDeployOpen] = useState(false);
|
const [deployOpen, setDeployOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [deployReplicas, setDeployReplicas] = useState('1');
|
|
||||||
const [deployCpuRequest, setDeployCpuRequest] = useState('100m');
|
|
||||||
const [deployCpuLimit, setDeployCpuLimit] = useState('500m');
|
|
||||||
const [deployMemoryRequest, setDeployMemoryRequest] = useState('128Mi');
|
|
||||||
const [deployMemoryLimit, setDeployMemoryLimit] = useState('256Mi');
|
|
||||||
const [releaseForm, setReleaseForm] = useState({
|
const [releaseForm, setReleaseForm] = useState({
|
||||||
buildJobId: '',
|
buildJobId: '',
|
||||||
version: '',
|
version: '',
|
||||||
@@ -133,16 +128,7 @@ export function LauncherVersionsPage() {
|
|||||||
const deployBackend = () => {
|
const deployBackend = () => {
|
||||||
if (!latestBackendBuild?.downloadUrl) return;
|
if (!latestBackendBuild?.downloadUrl) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
const requestedReplicas = Number.parseInt(deployReplicas, 10);
|
deployLauncherBackend(latestBackendBuild.downloadUrl)
|
||||||
const resources = {
|
|
||||||
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
|
||||||
...(deployCpuRequest.trim() ? { cpuRequest: deployCpuRequest.trim() } : {}),
|
|
||||||
...(deployCpuLimit.trim() ? { cpuLimit: deployCpuLimit.trim() } : {}),
|
|
||||||
...(deployMemoryRequest.trim() ? { memoryRequest: deployMemoryRequest.trim() } : {}),
|
|
||||||
...(deployMemoryLimit.trim() ? { memoryLimit: deployMemoryLimit.trim() } : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
deployLauncherBackend(latestBackendBuild.downloadUrl, Object.keys(resources).length ? resources : undefined)
|
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setDeployResult(result);
|
setDeployResult(result);
|
||||||
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||||
@@ -155,15 +141,6 @@ export function LauncherVersionsPage() {
|
|||||||
.finally(() => setDeploying(false));
|
.finally(() => setDeploying(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBackendDeploy = () => {
|
|
||||||
setDeployReplicas('1');
|
|
||||||
setDeployCpuRequest('100m');
|
|
||||||
setDeployCpuLimit('500m');
|
|
||||||
setDeployMemoryRequest('128Mi');
|
|
||||||
setDeployMemoryLimit('256Mi');
|
|
||||||
setDeployOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -182,7 +159,7 @@ export function LauncherVersionsPage() {
|
|||||||
릴리즈 등록
|
릴리즈 등록
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openBackendDeploy} disabled={!latestBackendBuild?.downloadUrl}>
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
백엔드 배포
|
백엔드 배포
|
||||||
</Button>
|
</Button>
|
||||||
@@ -316,7 +293,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">빌드 결과 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">빌드 결과</span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={releaseForm.buildJobId}
|
value={releaseForm.buildJobId}
|
||||||
@@ -332,7 +309,7 @@ export function LauncherVersionsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
||||||
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
|
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
|
||||||
{displayValue(releaseForm.version)}
|
{displayValue(releaseForm.version)}
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +324,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
<div className="rounded-md border border-border bg-background px-4 py-3">
|
||||||
<p className="text-xs text-muted-foreground">다운로드 URL <b className="text-foreground">*</b></p>
|
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -368,60 +345,6 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={deployReplicas}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setDeployReplicas(event.target.value)}
|
|
||||||
placeholder="1"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={deployCpuRequest}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setDeployCpuRequest(event.target.value)}
|
|
||||||
placeholder="예: 100m"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={deployCpuLimit}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setDeployCpuLimit(event.target.value)}
|
|
||||||
placeholder="예: 500m"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={deployMemoryRequest}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setDeployMemoryRequest(event.target.value)}
|
|
||||||
placeholder="예: 256Mi"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
|
||||||
value={deployMemoryLimit}
|
|
||||||
disabled={deploying}
|
|
||||||
onChange={(event) => setDeployMemoryLimit(event.target.value)}
|
|
||||||
placeholder="예: 512Mi"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
||||||
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
||||||
|
|||||||
@@ -29,14 +29,6 @@ export type LauncherBuildJob = {
|
|||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
artifact: string;
|
artifact: string;
|
||||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
||||||
requestedBy?: string | null;
|
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
createdBy?: string | null;
|
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -63,14 +55,6 @@ export type LauncherBackendDeployResult = {
|
|||||||
logs: string;
|
logs: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeploymentResourceLimits = {
|
|
||||||
replicas?: number;
|
|
||||||
cpuRequest?: string;
|
|
||||||
cpuLimit?: string;
|
|
||||||
memoryRequest?: string;
|
|
||||||
memoryLimit?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherBuildEnvVariable = {
|
export type LauncherBuildEnvVariable = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -113,16 +97,12 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
@@ -289,10 +269,10 @@ export function getLauncherBackendDeployment() {
|
|||||||
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deployLauncherBackend(image: string, resources?: DeploymentResourceLimits) {
|
export function deployLauncherBackend(image: string) {
|
||||||
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ image, resources: resources && Object.keys(resources).length ? resources : undefined })
|
body: JSON.stringify({ image })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
deleteAdminSshKey,
|
deleteAdminSshKey,
|
||||||
loadAdminAppPermissions,
|
loadAdminAppPermissions,
|
||||||
loadAdminRoles,
|
loadAdminRoles,
|
||||||
loadAdminsPage,
|
loadAdmins,
|
||||||
resetAdminPassword,
|
resetAdminPassword,
|
||||||
updateAdminAppPermissions,
|
updateAdminAppPermissions,
|
||||||
updateAdminRole,
|
updateAdminRole,
|
||||||
@@ -44,10 +44,6 @@ const fallbackAdminRoles: AdminRoleOption[] = [
|
|||||||
export function AdminManagementPage() {
|
export function AdminManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -59,9 +55,9 @@ export function AdminManagementPage() {
|
|||||||
const [sshTarget, setSshTarget] = useState({
|
const [sshTarget, setSshTarget] = useState({
|
||||||
admin: null as AdminAccount | null,
|
admin: null as AdminAccount | null,
|
||||||
label: '',
|
label: '',
|
||||||
|
giteaUsername: '',
|
||||||
publicKey: ''
|
publicKey: ''
|
||||||
});
|
});
|
||||||
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
|
|
||||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
@@ -79,13 +75,9 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
||||||
loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }),
|
.then(([nextAdmins, nextRoles]) => {
|
||||||
loadAdminRoles().catch(() => fallbackAdminRoles)
|
setAdmins(nextAdmins);
|
||||||
])
|
|
||||||
.then(([result, nextRoles]) => {
|
|
||||||
setAdmins(result.items);
|
|
||||||
setTotal(result.total);
|
|
||||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -99,7 +91,7 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [page, keyword, filterValues.role, filterValues.state]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuAdmin) return;
|
if (!menuAdmin) return;
|
||||||
@@ -118,7 +110,7 @@ export function AdminManagementPage() {
|
|||||||
}, [menuAdmin]);
|
}, [menuAdmin]);
|
||||||
|
|
||||||
const rows = useMemo<AdminRow[]>(
|
const rows = useMemo<AdminRow[]>(
|
||||||
() => admins.map((admin) => ({ ...admin, no: admin.no ?? 0, state: admin.isActive ? '활성' : '비활성' })),
|
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
||||||
[admins]
|
[admins]
|
||||||
);
|
);
|
||||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||||
@@ -127,7 +119,7 @@ export function AdminManagementPage() {
|
|||||||
setMenuAdmin(null);
|
setMenuAdmin(null);
|
||||||
const account = admin.email.split('@')[0] || 'admin';
|
const account = admin.email.split('@')[0] || 'admin';
|
||||||
const random = Math.random().toString(36).slice(2, 8);
|
const random = Math.random().toString(36).slice(2, 8);
|
||||||
setSshTarget({ admin, label: `${account}-${random}-key`, publicKey: '' });
|
setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshError('');
|
setSshError('');
|
||||||
};
|
};
|
||||||
@@ -179,13 +171,9 @@ export function AdminManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitAdmin = () => {
|
const submitAdmin = () => {
|
||||||
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminForm.email.trim())) {
|
|
||||||
toast({ type: 'error', title: '관리자 등록 실패', message: '올바른 이메일 주소를 입력해주세요.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
createAdmin({ ...adminForm, email: adminForm.email.trim().toLowerCase() })
|
createAdmin(adminForm)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSaveState('idle');
|
setSaveState('idle');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -219,11 +207,12 @@ export function AdminManagementPage() {
|
|||||||
setSshError('');
|
setSshError('');
|
||||||
createAdminSshKey(sshTarget.admin.id, {
|
createAdminSshKey(sshTarget.admin.id, {
|
||||||
label: sshTarget.label.trim() || undefined,
|
label: sshTarget.label.trim() || undefined,
|
||||||
|
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
||||||
publicKey: sshTarget.publicKey.trim()
|
publicKey: sshTarget.publicKey.trim()
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshTarget({ admin: null, label: '', publicKey: '' });
|
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
||||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -234,12 +223,9 @@ export function AdminManagementPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSshKey = () => {
|
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
||||||
if (!sshDeleteTarget) return;
|
deleteAdminSshKey(admin.id, keyId)
|
||||||
|
|
||||||
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSshDeleteTarget(null);
|
|
||||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -248,18 +234,15 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: '관리자',
|
header: '관리자',
|
||||||
className: 'w-[260px] px-5 py-3 font-medium',
|
className: 'w-[260px] px-5 py-3 font-medium',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div>
|
||||||
<AdminAvatar admin={row} />
|
<p className="font-semibold">{row.name}</p>
|
||||||
<div className="min-w-0">
|
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
||||||
<p className="truncate font-semibold">{row.name}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{row.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -295,7 +278,7 @@ export function AdminManagementPage() {
|
|||||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||||
onClick={() => openSshModal(row)}
|
onClick={() => openSshModal(row)}
|
||||||
>
|
>
|
||||||
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -330,10 +313,10 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), getValue: (row) => row.role },
|
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
||||||
{ key: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' }
|
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||||
],
|
],
|
||||||
[roleOptions]
|
[rows]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -366,38 +349,12 @@ export function AdminManagementPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 관리자가 없습니다."
|
emptyMessage="등록된 관리자가 없습니다."
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{menuAdmin ? (() => {
|
{menuAdmin ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
||||||
{row.sshKeys?.length ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
setMenuAdmin(null);
|
|
||||||
setSshDeleteTarget({ admin: row, keyId: row.sshKeys![0].id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SSH 키 삭제
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -405,7 +362,6 @@ export function AdminManagementPage() {
|
|||||||
>
|
>
|
||||||
SSH 키 등록
|
SSH 키 등록
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -440,26 +396,26 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title="관리자 추가"
|
title="관리자 등록"
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||||
<Button onClick={submitAdmin} disabled={saveState === 'saving' || !adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이메일</span>
|
||||||
<input type="email" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">이름</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">권한 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">권한</span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
||||||
{roleOptions.map((role) => (
|
{roleOptions.map((role) => (
|
||||||
<option key={role.code} value={role.code}>
|
<option key={role.code} value={role.code}>
|
||||||
@@ -469,7 +425,7 @@ export function AdminManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,11 +451,11 @@ export function AdminManagementPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={Boolean(sshTarget.admin)}
|
open={Boolean(sshTarget.admin)}
|
||||||
title="SSH 키 등록"
|
title="SSH 키 등록"
|
||||||
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })}
|
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', publicKey: '' })}>취소</Button>
|
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
|
||||||
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -509,9 +465,10 @@ export function AdminManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
<label className="block">
|
||||||
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
|
<span className="text-xs font-medium text-muted-foreground">저장소 사용자명</span>
|
||||||
</p>
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
|
||||||
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
||||||
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
||||||
@@ -525,7 +482,7 @@ export function AdminManagementPage() {
|
|||||||
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && setSshDeleteTarget({ admin: sshTarget.admin, keyId: key.id })}>
|
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,20 +492,6 @@ export function AdminManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(sshDeleteTarget)}
|
|
||||||
title="SSH 키 삭제"
|
|
||||||
onClose={() => setSshDeleteTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setSshDeleteTarget(null)}>취소</Button>
|
|
||||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={removeSshKey}>삭제</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">등록된 관리자 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(permissionTarget.admin)}
|
open={Boolean(permissionTarget.admin)}
|
||||||
title="앱 권한"
|
title="앱 권한"
|
||||||
@@ -604,25 +547,3 @@ export function AdminManagementPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminAvatar({ admin }: { admin: AdminAccount }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
|
||||||
|
|
||||||
if (admin.avatarUrl && !failed) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={admin.avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-9 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-sm font-semibold text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,23 +8,30 @@ import { formatDateOnly } from '../../lib/date-format';
|
|||||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
import {
|
import {
|
||||||
assignUserLicense,
|
assignUserLicense,
|
||||||
loadLicenseAppPermissions,
|
|
||||||
loadLicenseAssignments,
|
|
||||||
loadLicensePlans,
|
loadLicensePlans,
|
||||||
loadLicenseUsers,
|
loadLicenseUsers,
|
||||||
loadLauncherUserAppPermissions,
|
loadLauncherUserAppPermissions,
|
||||||
revokeUserLicense,
|
revokeUserLicense,
|
||||||
updateLicensePlan,
|
|
||||||
updateLauncherUserAppPermissions,
|
updateLauncherUserAppPermissions,
|
||||||
updateUserLicense,
|
updateUserLicense,
|
||||||
type AuthLicenseUser,
|
type AuthLicenseUser,
|
||||||
type LauncherUserAppPermission,
|
type LauncherUserAppPermission,
|
||||||
type LicenseAppPermission,
|
|
||||||
type LicenseAssignment,
|
|
||||||
type LicensePlan
|
type LicensePlan
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
|
|
||||||
type LicenseAssignmentRow = LicenseAssignment;
|
type LicenseAssignmentRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
code: string;
|
||||||
|
licenseName: string;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
assignedBy?: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
@@ -51,15 +58,28 @@ function toDateInputValue(value?: string | null) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignmentRows(users: AuthLicenseUser[]) {
|
||||||
|
return users.flatMap((user) =>
|
||||||
|
user.licenses.map((license) => ({
|
||||||
|
id: license.id,
|
||||||
|
userId: user.id,
|
||||||
|
user: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
code: license.license.code,
|
||||||
|
licenseName: license.license.name,
|
||||||
|
startsAt: license.startsAt,
|
||||||
|
expiresAt: license.expiresAt,
|
||||||
|
assignedBy: license.assignedById,
|
||||||
|
status: license.status
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LicensesPage() {
|
export function LicensesPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||||
const [rows, setRows] = useState<LicenseAssignmentRow[]>([]);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [assignOpen, setAssignOpen] = useState(false);
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||||
@@ -67,18 +87,8 @@ export function LicensesPage() {
|
|||||||
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||||
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
||||||
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
||||||
const [planEditTarget, setPlanEditTarget] = useState<LicensePlan | null>(null);
|
|
||||||
const [planStatusTarget, setPlanStatusTarget] = useState<LicensePlan | null>(null);
|
|
||||||
const [licenseAppPermissions, setLicenseAppPermissions] = useState<LicenseAppPermission[]>([]);
|
|
||||||
const [licenseAppLoading, setLicenseAppLoading] = useState(false);
|
|
||||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [planMenu, setPlanMenu] = useState<{ code: string; x: number; y: number } | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [planForm, setPlanForm] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
defaultDurationDays: ''
|
|
||||||
});
|
|
||||||
const [assignForm, setAssignForm] = useState({
|
const [assignForm, setAssignForm] = useState({
|
||||||
userId: '',
|
userId: '',
|
||||||
licenseCode: '',
|
licenseCode: '',
|
||||||
@@ -88,6 +98,7 @@ export function LicensesPage() {
|
|||||||
const [userKeyword, setUserKeyword] = useState('');
|
const [userKeyword, setUserKeyword] = useState('');
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const rows = useMemo(() => assignmentRows(users), [users]);
|
||||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||||
const plan = plans.find((item) => item.code === licenseCode);
|
const plan = plans.find((item) => item.code === licenseCode);
|
||||||
return addDays(startsAt, plan?.defaultDurationDays);
|
return addDays(startsAt, plan?.defaultDurationDays);
|
||||||
@@ -105,17 +116,8 @@ export function LicensesPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||||
const nextAssignments = await loadLicenseAssignments({
|
|
||||||
page,
|
|
||||||
limit: 6,
|
|
||||||
keyword,
|
|
||||||
licenseCode: filterValues.licenseName,
|
|
||||||
status: filterValues.status,
|
|
||||||
});
|
|
||||||
setPlans(nextPlans);
|
setPlans(nextPlans);
|
||||||
setUsers(nextUsers);
|
setUsers(nextUsers);
|
||||||
setRows(nextAssignments.items);
|
|
||||||
setTotal(nextAssignments.total);
|
|
||||||
setAssignForm((prev) => ({
|
setAssignForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||||
@@ -130,17 +132,14 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [page, keyword, filterValues.licenseName, filterValues.status]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuRow && !planMenu) return;
|
if (!menuRow) return;
|
||||||
|
|
||||||
const close = () => {
|
const close = () => setMenuRow(null);
|
||||||
setMenuRow(null);
|
|
||||||
setPlanMenu(null);
|
|
||||||
};
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') close();
|
if (event.key === 'Escape') setMenuRow(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('click', close);
|
window.addEventListener('click', close);
|
||||||
@@ -149,7 +148,7 @@ export function LicensesPage() {
|
|||||||
window.removeEventListener('click', close);
|
window.removeEventListener('click', close);
|
||||||
window.removeEventListener('keydown', closeOnEscape);
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
};
|
};
|
||||||
}, [menuRow, planMenu]);
|
}, [menuRow]);
|
||||||
|
|
||||||
const openAssignModal = () => {
|
const openAssignModal = () => {
|
||||||
const startsAt = today();
|
const startsAt = today();
|
||||||
@@ -277,76 +276,8 @@ export function LicensesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPlanEditModal = async (plan: LicensePlan) => {
|
|
||||||
setPlanMenu(null);
|
|
||||||
setPlanEditTarget(plan);
|
|
||||||
setPlanForm({
|
|
||||||
name: plan.name,
|
|
||||||
description: plan.description ?? '',
|
|
||||||
defaultDurationDays: plan.defaultDurationDays ? String(plan.defaultDurationDays) : ''
|
|
||||||
});
|
|
||||||
setLicenseAppPermissions([]);
|
|
||||||
setLicenseAppLoading(true);
|
|
||||||
try {
|
|
||||||
setLicenseAppPermissions(await loadLicenseAppPermissions(plan.code));
|
|
||||||
} catch (error) {
|
|
||||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 앱 설정을 불러오지 못했습니다.' });
|
|
||||||
} finally {
|
|
||||||
setLicenseAppLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePlanEditModal = () => {
|
|
||||||
setPlanEditTarget(null);
|
|
||||||
setLicenseAppPermissions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const savePlanEdit = async () => {
|
|
||||||
if (!planEditTarget || !planForm.name.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const result = await updateLicensePlan(planEditTarget.code, {
|
|
||||||
name: planForm.name.trim(),
|
|
||||||
description: planForm.description.trim() || null,
|
|
||||||
defaultDurationDays: planForm.defaultDurationDays ? Number(planForm.defaultDurationDays) : null,
|
|
||||||
permissions: licenseAppPermissions.map((permission) => ({
|
|
||||||
appKey: permission.appKey,
|
|
||||||
canLaunch: permission.canLaunch
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
toast({ type: 'success', message: `라이센스를 수정했습니다. ${result.updatedUserCount ?? 0}명에게 반영했습니다.` });
|
|
||||||
closePlanEditModal();
|
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 수정에 실패했습니다.' });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlanActive = async (plan: LicensePlan) => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await updateLicensePlan(plan.code, { isActive: !plan.isActive });
|
|
||||||
toast({ type: 'success', message: plan.isActive ? '라이센스를 비활성화했습니다.' : '라이센스를 활성화했습니다.' });
|
|
||||||
setPlanStatusTarget(null);
|
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 상태 변경에 실패했습니다.' });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleLicenseAppLaunch = (appKey: string) => {
|
|
||||||
setLicenseAppPermissions((prev) => prev.map((permission) =>
|
|
||||||
permission.appKey === appKey ? { ...permission, canLaunch: !permission.canLaunch, canView: true } : permission
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
|
||||||
{
|
{
|
||||||
key: 'user',
|
key: 'user',
|
||||||
header: '사용자',
|
header: '사용자',
|
||||||
@@ -391,10 +322,10 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code },
|
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
||||||
{ key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' }
|
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
||||||
],
|
],
|
||||||
[plans]
|
[plans, rows]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -406,7 +337,7 @@ export function LicensesPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
등록
|
라이센스 부여
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -418,22 +349,7 @@ export function LicensesPage() {
|
|||||||
<p className="text-sm text-foreground">{plan.name}</p>
|
<p className="text-sm text-foreground">{plan.name}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
||||||
{!plan.isActive ? <StatusBadge status="비활성" /> : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 px-0"
|
|
||||||
title="메뉴"
|
|
||||||
aria-label="메뉴"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
setPlanMenu((prev) => (prev?.code === plan.code ? null : { code: plan.code, x: rect.right, y: rect.bottom + 6 }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -458,34 +374,9 @@ export function LicensesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{planMenu ? (() => {
|
|
||||||
const plan = plans.find((item) => item.code === planMenu.code);
|
|
||||||
if (!plan) return null;
|
|
||||||
return (
|
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: planMenu.x, top: planMenu.y, transform: 'translateX(-100%)' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
|
||||||
onClick={() => void openPlanEditModal(plan)}
|
|
||||||
>
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
setPlanMenu(null);
|
|
||||||
setPlanStatusTarget(plan);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{plan.isActive ? '비활성' : '활성'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : null}
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="목록"
|
title="부여 이력"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -496,20 +387,6 @@ export function LicensesPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="부여된 라이센스가 없습니다."
|
emptyMessage="부여된 라이센스가 없습니다."
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -551,7 +428,7 @@ export function LicensesPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
{editingRow ? '라이센스 수정' : '라이센스 추가'}
|
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeAssignModal}
|
onClose={closeAssignModal}
|
||||||
@@ -567,7 +444,7 @@ export function LicensesPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{editingRow ? (
|
{editingRow ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||||
<p className="text-foreground">{editingRow.user}</p>
|
<p className="text-foreground">{editingRow.user}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||||
@@ -623,7 +500,7 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={assignForm.licenseCode}
|
value={assignForm.licenseCode}
|
||||||
@@ -641,7 +518,7 @@ export function LicensesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">시작일 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
@@ -668,110 +545,6 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
open={Boolean(planEditTarget)}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Pencil className="size-4 text-primary" />
|
|
||||||
라이센스 수정
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClose={closePlanEditModal}
|
|
||||||
className="max-w-2xl"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={closePlanEditModal} disabled={saving}>취소</Button>
|
|
||||||
<Button onClick={savePlanEdit} disabled={saving || licenseAppLoading || !planForm.name.trim()}>
|
|
||||||
{saving ? '저장 중' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">라이센스명 <b className="text-foreground">*</b></span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
||||||
value={planForm.name}
|
|
||||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, name: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">기본 기간</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
||||||
value={planForm.defaultDurationDays}
|
|
||||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, defaultDurationDays: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
|
||||||
<textarea
|
|
||||||
className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
||||||
value={planForm.description}
|
|
||||||
onChange={(event) => setPlanForm((prev) => ({ ...prev, description: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱 실행 설정</span>
|
|
||||||
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border border-border">
|
|
||||||
{licenseAppLoading ? (
|
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">앱 설정을 불러오는 중입니다.</div>
|
|
||||||
) : licenseAppPermissions.length === 0 ? (
|
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
|
|
||||||
) : (
|
|
||||||
licenseAppPermissions.map((permission) => (
|
|
||||||
<div key={permission.appKey} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm text-foreground">{permission.displayName}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{permission.appKey}</p>
|
|
||||||
</div>
|
|
||||||
<label className="inline-flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="size-4 accent-primary"
|
|
||||||
checked={permission.canLaunch}
|
|
||||||
onChange={() => toggleLicenseAppLaunch(permission.appKey)}
|
|
||||||
/>
|
|
||||||
실행
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
|
||||||
open={Boolean(planStatusTarget)}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-4 text-primary" />
|
|
||||||
라이센스 상태 변경
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClose={() => setPlanStatusTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setPlanStatusTarget(null)} disabled={saving}>취소</Button>
|
|
||||||
<Button
|
|
||||||
className={planStatusTarget?.isActive ? 'border border-amber-500/40 bg-amber-500/15 text-amber-100 hover:bg-amber-500/25' : undefined}
|
|
||||||
onClick={() => planStatusTarget ? void togglePlanActive(planStatusTarget) : undefined}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? '처리 중' : planStatusTarget?.isActive ? '비활성' : '활성'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="pb-6 text-sm text-foreground">
|
|
||||||
{planStatusTarget?.name ?? '라이센스'}를 {planStatusTarget?.isActive ? '비활성화' : '활성화'}할까요?
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
|
||||||
import { useToast } from '../../components/toast';
|
|
||||||
import { Button, Modal } from '../../components/ui';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
|
||||||
import { formatDateTime } from '../../lib/date-format';
|
|
||||||
import { AppSelector, useApps } from '../applications/ApplicationControls';
|
|
||||||
import {
|
|
||||||
createManualDocument,
|
|
||||||
deleteManualDocument,
|
|
||||||
loadManualDocumentsPage,
|
|
||||||
type ManualDocument,
|
|
||||||
updateManualDocument,
|
|
||||||
} from './operations-api';
|
|
||||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
|
||||||
|
|
||||||
type ManualForm = Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>;
|
|
||||||
|
|
||||||
const emptyForm: ManualForm = { appKey: '', title: '', body: '', isPublished: true, sortOrder: 0 };
|
|
||||||
|
|
||||||
function ManualCreator({ document }: { document: ManualDocument }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = document.createdByName || document.createdByEmail || '-';
|
|
||||||
const avatarUrl = document.createdByAvatarUrl || '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{avatarUrl && !failed ? (
|
|
||||||
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
|
||||||
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(document.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ManualsPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [documents, setDocuments] = useState<ManualDocument[]>([]);
|
|
||||||
const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
|
||||||
const selectedAppKey = selectedApp?.key ?? '';
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<ManualDocument | null>(null);
|
|
||||||
const [form, setForm] = useState<ManualForm>(emptyForm);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ManualDocument | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
if (!selectedAppKey) {
|
|
||||||
setDocuments([]);
|
|
||||||
setTotal(0);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
loadManualDocumentsPage({ page, limit: 6, keyword, appKey: selectedAppKey })
|
|
||||||
.then((result) => { setDocuments(result.items); setTotal(result.total); setFailed(false); })
|
|
||||||
.catch((error: Error) => { setDocuments([]); setTotal(0); setFailed(true); toast({ type: 'error', title: '매뉴얼 조회 실패', message: error.message }); })
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [page, keyword, selectedAppKey]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!menu) return;
|
|
||||||
const close = () => setMenu(null);
|
|
||||||
window.addEventListener('click', close);
|
|
||||||
return () => window.removeEventListener('click', close);
|
|
||||||
}, [menu]);
|
|
||||||
|
|
||||||
const openCreate = () => { setEditing(null); setForm({ ...emptyForm, appKey: selectedAppKey }); setModalOpen(true); };
|
|
||||||
const openEdit = (document: ManualDocument) => {
|
|
||||||
setMenu(null);
|
|
||||||
setEditing(document);
|
|
||||||
setForm({ appKey: document.appKey, title: document.title, body: document.body, isPublished: document.isPublished, sortOrder: document.sortOrder });
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
const closeModal = () => { setEditing(null); setForm(emptyForm); setModalOpen(false); };
|
|
||||||
const submit = () => {
|
|
||||||
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
const payload = { ...form, appKey: form.appKey.trim(), title: form.title.trim(), body: form.body.trim() };
|
|
||||||
const request = editing ? updateManualDocument(editing.id, payload) : createManualDocument(payload);
|
|
||||||
request
|
|
||||||
.then(() => { toast({ type: 'success', message: editing ? '매뉴얼을 수정했습니다.' : '매뉴얼을 추가했습니다.' }); closeModal(); refresh(); })
|
|
||||||
.catch((error: Error) => toast({ type: 'error', title: editing ? '매뉴얼 수정 실패' : '매뉴얼 추가 실패', message: error.message }))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
const confirmDelete = () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setSaving(true);
|
|
||||||
deleteManualDocument(deleteTarget.id)
|
|
||||||
.then(() => { toast({ type: 'success', message: '매뉴얼을 삭제했습니다.' }); setDeleteTarget(null); refresh(); })
|
|
||||||
.catch((error: Error) => toast({ type: 'error', title: '매뉴얼 삭제 실패', message: error.message }))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<ManualDocument>>>(() => [
|
|
||||||
{ key: 'no', header: 'No.', className: 'px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
|
||||||
{ key: 'title', header: '문서명', width: 220, className: 'px-5 py-3 font-medium', cell: (row) => row.title },
|
|
||||||
{ key: 'body', header: '본문', flexible: true, className: 'px-5 py-3', cell: (row) => <p className="truncate">{row.body}</p> },
|
|
||||||
{ key: 'published', header: '공개', width: 100, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.isPublished ? '공개' : '비공개'} tone={row.isPublished ? 'green' : 'neutral'} /> },
|
|
||||||
{ key: 'sortOrder', header: '순서', width: 80, className: 'px-5 py-3 text-center', cell: (row) => row.sortOrder },
|
|
||||||
{ key: 'createdAt', header: '생성', width: 190, className: 'px-5 py-3', cell: (row) => <ManualCreator document={row} /> },
|
|
||||||
{ key: 'actions', header: '', className: 'px-5 py-3', cell: (row) => <div className="relative flex justify-end"><Button variant="ghost" className="size-8 px-0" title="메뉴" aria-label="메뉴" onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); setMenu((previous) => previous?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }); }}><MoreHorizontal className="size-4" /></Button></div> },
|
|
||||||
], []);
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<PageTitle eyebrow="운영" title="매뉴얼" description="런처 사용 매뉴얼의 Markdown 문서를 관리합니다." actions={<Button className="h-8 px-3 text-[13px]" onClick={openCreate} disabled={!selectedAppKey}><Plus className="size-4" />추가</Button>} />
|
|
||||||
{failed && <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">매뉴얼 데이터를 불러오지 못했습니다.</div>}
|
|
||||||
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={(appId) => { selectApp(appId); setPage(1); }} />
|
|
||||||
<DataTableCard title="목록" rows={documents} columns={columns} searchPlaceholder="문서명, 본문 검색" getSearchText={(row) => `${row.title} ${row.body}`} getRowKey={(row) => row.id} pageSize={6} minWidth={980} loading={loading} emptyMessage="등록된 매뉴얼이 없습니다." serverPagination={{ page, total, onPageChange: setPage, onSearchChange: (value) => { setKeyword(value); setPage(1); } }} />
|
|
||||||
{menu && (() => {
|
|
||||||
const document = documents.find((item) => item.id === menu.id);
|
|
||||||
if (!document) return null;
|
|
||||||
return <div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menu.x, top: menu.y, transform: 'translateX(-100%)' }}>
|
|
||||||
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(document)}>수정</button>
|
|
||||||
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => { setMenu(null); setDeleteTarget(document); }}>삭제</button>
|
|
||||||
</div>;
|
|
||||||
})()}
|
|
||||||
<Modal open={modalOpen} title={<span className="inline-flex items-center gap-2"><Pencil className="size-4 text-primary" />{editing ? '매뉴얼 수정' : '매뉴얼 추가'}</span>} onClose={closeModal} className="!max-w-3xl" footer={<><Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button><Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>{saving ? '저장 중' : '저장'}</Button></>}>
|
|
||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_96px]">
|
|
||||||
<label className="block"><span className="text-xs font-medium text-muted-foreground">문서명 <b className="text-foreground">*</b></span><input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} /></label>
|
|
||||||
<label className="block"><span className="text-xs font-medium text-muted-foreground">순서</span><input type="number" min="0" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.sortOrder} onChange={(event) => setForm((prev) => ({ ...prev, sortOrder: Number(event.target.value) || 0 }))} /></label>
|
|
||||||
</div>
|
|
||||||
<label className="block"><span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span><textarea className="mt-2 min-h-72 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} /></label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-foreground"><input type="checkbox" checked={form.isPublished} onChange={(event) => setForm((prev) => ({ ...prev, isPublished: event.target.checked }))} />공개</label>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
<Modal open={Boolean(deleteTarget)} title={<span className="inline-flex items-center gap-2"><Trash2 className="size-4 text-rose-300" />매뉴얼 삭제</span>} onClose={() => setDeleteTarget(null)} footer={<><Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button><Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button></>}><p className="pb-6 text-sm text-foreground">매뉴얼 문서를 삭제할까요?</p></Modal>
|
|
||||||
<LoadingModal open={loadingVisible} />
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
|
|||||||
import {
|
import {
|
||||||
createLauncherNotice,
|
createLauncherNotice,
|
||||||
deleteLauncherNotice,
|
deleteLauncherNotice,
|
||||||
loadLauncherNoticesPage,
|
loadLauncherNotices,
|
||||||
updateLauncherNotice,
|
updateLauncherNotice,
|
||||||
type LauncherNotice
|
type LauncherNotice
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -21,7 +21,6 @@ type NoticeForm = {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
linkUrl: string;
|
linkUrl: string;
|
||||||
exposureEnabled: boolean;
|
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
@@ -33,7 +32,6 @@ const emptyForm: NoticeForm = {
|
|||||||
title: '',
|
title: '',
|
||||||
body: '',
|
body: '',
|
||||||
linkUrl: '',
|
linkUrl: '',
|
||||||
exposureEnabled: false,
|
|
||||||
startsAt: '',
|
startsAt: '',
|
||||||
endsAt: '',
|
endsAt: '',
|
||||||
priority: '0',
|
priority: '0',
|
||||||
@@ -73,41 +71,12 @@ function exposureCell(row: LauncherNotice) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoticeCreator({ notice }: { notice: LauncherNotice }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = notice.createdByName || notice.createdByEmail || 'admin';
|
|
||||||
const avatarUrl = notice.createdByAvatarUrl || '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{avatarUrl && !failed ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
|
||||||
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatNoticeDateTime(notice.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toForm(notice: LauncherNotice): NoticeForm {
|
function toForm(notice: LauncherNotice): NoticeForm {
|
||||||
return {
|
return {
|
||||||
appKey: notice.appKey,
|
appKey: notice.appKey,
|
||||||
title: notice.title,
|
title: notice.title,
|
||||||
body: notice.body,
|
body: notice.body,
|
||||||
linkUrl: notice.linkUrl ?? '',
|
linkUrl: notice.linkUrl ?? '',
|
||||||
exposureEnabled: Boolean(notice.startsAt || notice.endsAt),
|
|
||||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||||
priority: String(notice.priority ?? 0),
|
priority: String(notice.priority ?? 0),
|
||||||
@@ -123,10 +92,6 @@ export function NoticesPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -140,12 +105,11 @@ export function NoticesPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }),
|
loadLauncherNotices(),
|
||||||
listApps().catch(() => [])
|
listApps().catch(() => [])
|
||||||
])
|
])
|
||||||
.then(([result, appItems]) => {
|
.then(([items, appItems]) => {
|
||||||
setNotices(result.items);
|
setNotices(items);
|
||||||
setTotal(result.total);
|
|
||||||
setApps(appItems);
|
setApps(appItems);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -159,7 +123,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [page, keyword, filterValues.appKey]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuNotice) return;
|
if (!menuNotice) return;
|
||||||
@@ -189,7 +153,7 @@ export function NoticesPage() {
|
|||||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => notices.map((notice) => ({ ...notice, no: notice.no ?? 0, appName: appLabel(notice.appKey) })),
|
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
||||||
[appNameByKey, notices]
|
[appNameByKey, notices]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -217,8 +181,8 @@ export function NoticesPage() {
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
body: form.body.trim(),
|
body: form.body.trim(),
|
||||||
linkUrl: form.linkUrl.trim(),
|
linkUrl: form.linkUrl.trim(),
|
||||||
startsAt: form.exposureEnabled ? toApiDateTime(form.startsAt) : undefined,
|
startsAt: toApiDateTime(form.startsAt),
|
||||||
endsAt: form.exposureEnabled ? toApiDateTime(form.endsAt) : undefined,
|
endsAt: toApiDateTime(form.endsAt),
|
||||||
priority: Number(form.priority) || 0,
|
priority: Number(form.priority) || 0,
|
||||||
isActive: form.isActive
|
isActive: form.isActive
|
||||||
});
|
});
|
||||||
@@ -265,7 +229,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: '공지',
|
header: '공지',
|
||||||
@@ -279,7 +243,7 @@ export function NoticesPage() {
|
|||||||
},
|
},
|
||||||
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
||||||
{ key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell },
|
{ key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell },
|
||||||
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <NoticeCreator notice={row} /> },
|
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -308,14 +272,9 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey }
|
||||||
key: 'appKey',
|
|
||||||
label: '대상 앱',
|
|
||||||
options: ['Launcher', ...apps.map((app) => app.key)].map((appKey) => ({ label: appLabel(appKey), value: appKey })),
|
|
||||||
getValue: (row) => row.appKey
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
[apps, appNameByKey]
|
[rows]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -348,20 +307,6 @@ export function NoticesPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 공지사항이 없습니다."
|
emptyMessage="등록된 공지사항이 없습니다."
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{menuNotice ? (() => {
|
{menuNotice ? (() => {
|
||||||
@@ -392,7 +337,7 @@ export function NoticesPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
{editing ? '공지사항 수정' : '공지사항 추가'}
|
공지사항
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
@@ -409,7 +354,7 @@ export function NoticesPage() {
|
|||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">대상 앱 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">대상 앱</span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||||
<option value="Launcher">Launcher</option>
|
<option value="Launcher">Launcher</option>
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
@@ -426,33 +371,18 @@ export function NoticesPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">제목 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
||||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="inline-flex items-center gap-2 text-sm font-medium text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.exposureEnabled}
|
|
||||||
onChange={(event) => setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
exposureEnabled: event.target.checked,
|
|
||||||
startsAt: event.target.checked ? prev.startsAt : '',
|
|
||||||
endsAt: event.target.checked ? prev.endsAt : ''
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
노출기간 설정
|
|
||||||
</label>
|
|
||||||
{form.exposureEnabled ? (
|
|
||||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||||
@@ -462,8 +392,6 @@ export function NoticesPage() {
|
|||||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import {
|
import {
|
||||||
createOrganization,
|
createOrganization,
|
||||||
deleteOrganization,
|
deleteOrganization,
|
||||||
loadLicensePlans,
|
loadOrganizations,
|
||||||
loadOrganizationsPage,
|
|
||||||
updateOrganization,
|
updateOrganization,
|
||||||
updateOrganizationLicense,
|
|
||||||
type LicensePlan,
|
|
||||||
type Organization,
|
type Organization,
|
||||||
type OrganizationType
|
type OrganizationType
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -36,41 +33,14 @@ function organizationTypeLabel(type: OrganizationType) {
|
|||||||
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrganizationCreator({ organization }: { organization: Organization }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = organization.createdByName || organization.createdByEmail || '-';
|
|
||||||
const avatarUrl = organization.createdByAvatarUrl || '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{avatarUrl && !failed ? (
|
|
||||||
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
|
||||||
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(organization.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrganizationsPage() {
|
export function OrganizationsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Organization | null>(null);
|
const [editing, setEditing] = useState<Organization | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
||||||
const [licenseTarget, setLicenseTarget] = useState<{ organization: Organization; licenseCode: string } | null>(null);
|
|
||||||
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -78,10 +48,9 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type })
|
loadOrganizations()
|
||||||
.then((result) => {
|
.then((items) => {
|
||||||
setOrganizations(result.items);
|
setOrganizations(items);
|
||||||
setTotal(result.total);
|
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -94,10 +63,7 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
loadLicensePlans()
|
}, []);
|
||||||
.then(setLicensePlans)
|
|
||||||
.catch(() => setLicensePlans([]));
|
|
||||||
}, [page, keyword, filterValues.type]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOrganization) return;
|
if (!menuOrganization) return;
|
||||||
@@ -116,13 +82,13 @@ export function OrganizationsPage() {
|
|||||||
}, [menuOrganization]);
|
}, [menuOrganization]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })),
|
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
|
||||||
[organizations]
|
[organizations]
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<Organization>>>(
|
const columns = useMemo<Array<DataTableColumn<Organization>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: '조직명',
|
header: '조직명',
|
||||||
@@ -135,10 +101,8 @@ export function OrganizationsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
|
||||||
{ key: 'instructorInviteCode', header: '강사 가입 코드', className: 'w-40 px-5 py-3', cell: (row) => <span className="font-mono text-sm font-semibold tracking-[0.08em] text-foreground">{row.instructorInviteCode}</span> },
|
|
||||||
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -256,7 +220,7 @@ export function OrganizationsPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
추가
|
조직 등록
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -277,20 +241,6 @@ export function OrganizationsPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 조직 데이터가 없습니다."
|
emptyMessage="표시할 조직 데이터가 없습니다."
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{menuOrganization ? (() => {
|
{menuOrganization ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuOrganization.id);
|
const row = rows.find((item) => item.id === menuOrganization.id);
|
||||||
@@ -298,16 +248,6 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOrganization(null);
|
|
||||||
setLicenseTarget({ organization: row, licenseCode: row.defaultLicenseCode ?? '' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
라이센스 설정
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -331,65 +271,9 @@ export function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : null}
|
})() : null}
|
||||||
<Modal
|
|
||||||
open={Boolean(licenseTarget)}
|
|
||||||
title="조직 라이센스 설정"
|
|
||||||
onClose={() => setLicenseTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setLicenseTarget(null)} disabled={saving}>취소</Button>
|
|
||||||
<Button
|
|
||||||
disabled={saving || !licenseTarget}
|
|
||||||
onClick={() => {
|
|
||||||
if (!licenseTarget) return;
|
|
||||||
setSaving(true);
|
|
||||||
updateOrganizationLicense(licenseTarget.organization.id, licenseTarget.licenseCode || null)
|
|
||||||
.then((result) => {
|
|
||||||
toast({
|
|
||||||
type: 'success',
|
|
||||||
title: '라이센스 설정 완료',
|
|
||||||
message: `${result.updatedUserCount}명의 사용자 라이센스를 갱신했습니다.`
|
|
||||||
});
|
|
||||||
setLicenseTarget(null);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '라이센스 설정 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? '저장 중' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{licenseTarget ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border border-border bg-background/70 px-3 py-2 text-sm">
|
|
||||||
<p className="font-semibold text-foreground">{licenseTarget.organization.name}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">저장하면 해당 조직에 소속된 사용자의 현재 라이센스가 선택한 라이센스로 갱신됩니다.</p>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={licenseTarget.licenseCode}
|
|
||||||
onChange={(event) => setLicenseTarget((prev) => prev ? { ...prev, licenseCode: event.target.value } : prev)}
|
|
||||||
>
|
|
||||||
<option value="">없음</option>
|
|
||||||
{licensePlans.map((plan) => (
|
|
||||||
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title={editing ? '조직 수정' : '조직 추가'}
|
title={editing ? '조직 수정' : '조직 등록'}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -400,15 +284,16 @@ export function OrganizationsPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">조직명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">조직명</span>
|
||||||
<input
|
<input
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder="예: 영남대학교"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
@@ -425,6 +310,7 @@ export function OrganizationsPage() {
|
|||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
value={form.domain}
|
value={form.domain}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||||
|
placeholder="yu.ac.kr"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
|
||||||
import { useToast } from '../../components/toast';
|
|
||||||
import { Button, Modal } from '../../components/ui';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
|
||||||
import { formatDateTime } from '../../lib/date-format';
|
|
||||||
import {
|
|
||||||
createTerm,
|
|
||||||
deleteTerm,
|
|
||||||
loadTermsPage,
|
|
||||||
updateTerm,
|
|
||||||
type LauncherTerm
|
|
||||||
} from './operations-api';
|
|
||||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
|
||||||
|
|
||||||
type TermForm = {
|
|
||||||
key: string;
|
|
||||||
body: string;
|
|
||||||
isRequired: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const termKeyOptions = [
|
|
||||||
{ value: 'terms', label: '이용약관' },
|
|
||||||
{ value: 'privacy', label: '개인정보 처리방침' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const emptyForm: TermForm = {
|
|
||||||
key: 'terms',
|
|
||||||
body: '',
|
|
||||||
isRequired: true
|
|
||||||
};
|
|
||||||
|
|
||||||
function termKeyLabel(key: string) {
|
|
||||||
return termKeyOptions.find((option) => option.value === key)?.label ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function termKeyTone(key: string) {
|
|
||||||
return key === 'privacy' ? 'blue' as const : 'violet' as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TermCreator({ term }: { term: LauncherTerm }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const name = term.createdByName || term.createdByEmail || '-';
|
|
||||||
const avatarUrl = term.createdByAvatarUrl || '';
|
|
||||||
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{avatarUrl && !failed ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
|
||||||
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(term.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toForm(term: LauncherTerm): TermForm {
|
|
||||||
return {
|
|
||||||
key: term.key,
|
|
||||||
body: term.body,
|
|
||||||
isRequired: term.isRequired
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TermsPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [terms, setTerms] = useState<LauncherTerm[]>([]);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<LauncherTerm | null>(null);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LauncherTerm | null>(null);
|
|
||||||
const [menuTerm, setMenuTerm] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
||||||
const [form, setForm] = useState<TermForm>(emptyForm);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
loadTermsPage({ page, limit: 6, keyword, key: filterValues.key })
|
|
||||||
.then((result) => {
|
|
||||||
setTerms(result.items);
|
|
||||||
setTotal(result.total);
|
|
||||||
setFailed(false);
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
setTerms([]);
|
|
||||||
setTotal(0);
|
|
||||||
setFailed(true);
|
|
||||||
toast({ type: 'error', title: '약관 조회 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [page, keyword, filterValues.key]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!menuTerm) return;
|
|
||||||
|
|
||||||
const close = () => setMenuTerm(null);
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setMenuTerm(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('click', close);
|
|
||||||
window.addEventListener('keydown', closeOnEscape);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('click', close);
|
|
||||||
window.removeEventListener('keydown', closeOnEscape);
|
|
||||||
};
|
|
||||||
}, [menuTerm]);
|
|
||||||
|
|
||||||
const rows = useMemo(
|
|
||||||
() => terms.map((term) => ({ ...term, no: term.no ?? 0, keyLabel: termKeyLabel(term.key) })),
|
|
||||||
[terms]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm(emptyForm);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (term: LauncherTerm) => {
|
|
||||||
setMenuTerm(null);
|
|
||||||
setEditing(term);
|
|
||||||
setForm(toForm(term));
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setModalOpen(false);
|
|
||||||
setEditing(null);
|
|
||||||
setForm(emptyForm);
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = () => ({
|
|
||||||
key: form.key.trim(),
|
|
||||||
body: form.body.trim(),
|
|
||||||
isRequired: form.isRequired
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if (!form.key.trim() || !form.body.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
const request = editing ? updateTerm(editing.id, payload()) : createTerm(payload());
|
|
||||||
|
|
||||||
request
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: editing ? '약관을 수정했습니다.' : '약관을 추가했습니다.' });
|
|
||||||
closeModal();
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: editing ? '약관 수정 실패' : '약관 추가 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTerm = (term: LauncherTerm) => {
|
|
||||||
setMenuTerm(null);
|
|
||||||
setDeleteTarget(term);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmRemoveTerm = () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setSaving(true);
|
|
||||||
deleteTerm(deleteTarget.id)
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: '약관을 삭제했습니다.' });
|
|
||||||
setDeleteTarget(null);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '약관 삭제 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
|
||||||
() => [
|
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
|
||||||
{ key: 'key', header: '약관명', width: 160, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.keyLabel} tone={termKeyTone(row.key)} /> },
|
|
||||||
{
|
|
||||||
key: 'body',
|
|
||||||
header: '본문',
|
|
||||||
flexible: true,
|
|
||||||
className: 'px-5 py-3 font-medium',
|
|
||||||
cell: (row) => <p className="truncate">{row.body}</p>
|
|
||||||
},
|
|
||||||
{ key: 'required', header: '필수 여부', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.isRequired ? '필수' : '선택'} tone={row.isRequired ? 'amber' : 'neutral'} /> },
|
|
||||||
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <TermCreator term={row} /> },
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-20 px-5 py-3 font-medium',
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="relative flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 px-0"
|
|
||||||
title="메뉴"
|
|
||||||
aria-label="메뉴"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
setMenuTerm((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'key',
|
|
||||||
label: '약관명',
|
|
||||||
options: termKeyOptions,
|
|
||||||
getValue: (row) => row.key
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
eyebrow="운영"
|
|
||||||
title="약관"
|
|
||||||
description="회원가입과 서비스 이용에 필요한 약관을 관리합니다."
|
|
||||||
actions={
|
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{failed ? (
|
|
||||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
|
||||||
약관 데이터를 불러오지 못했습니다.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<DataTableCard
|
|
||||||
title="목록"
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
filters={filters}
|
|
||||||
searchPlaceholder="약관 본문, 약관명 검색"
|
|
||||||
getSearchText={(row) => `${row.body} ${row.key} ${row.keyLabel}`}
|
|
||||||
getRowKey={(row) => row.id}
|
|
||||||
pageSize={6}
|
|
||||||
minWidth={980}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage="등록된 약관이 없습니다."
|
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{menuTerm ? (() => {
|
|
||||||
const row = rows.find((item) => item.id === menuTerm.id);
|
|
||||||
if (!row) return null;
|
|
||||||
return (
|
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuTerm.x, top: menuTerm.y, transform: 'translateX(-100%)' }}>
|
|
||||||
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(row)}>
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => removeTerm(row)}>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : null}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Pencil className="size-4 text-primary" />
|
|
||||||
{editing ? '약관 수정' : '약관 추가'}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClose={closeModal}
|
|
||||||
className="!max-w-2xl"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
|
||||||
<Button onClick={submit} disabled={saving || !form.key.trim() || !form.body.trim()}>
|
|
||||||
{saving ? '저장 중' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">약관명 <b className="text-foreground">*</b></span>
|
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))}>
|
|
||||||
{termKeyOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">필수 여부</span>
|
|
||||||
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
|
||||||
<input type="checkbox" checked={form.isRequired} onChange={(event) => setForm((prev) => ({ ...prev, isRequired: event.target.checked }))} />
|
|
||||||
필수
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span>
|
|
||||||
<textarea className="mt-2 min-h-56 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Trash2 className="size-4 text-rose-300" />
|
|
||||||
약관 삭제
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClose={() => setDeleteTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
|
||||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveTerm} disabled={saving}>
|
|
||||||
{saving ? '삭제 중' : '삭제'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="pb-6 text-sm text-foreground">약관을 삭제할까요?</p>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<LoadingModal open={loadingVisible} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,25 @@
|
|||||||
import { MoreHorizontal, Plus } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
import { Button, Modal } from '../../components/ui';
|
import { Button, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import {
|
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||||
createManagedUser,
|
|
||||||
loadLicensePlans,
|
|
||||||
loadOrganizations,
|
|
||||||
loadUserSessionsPage,
|
|
||||||
lockUser,
|
|
||||||
terminateUserSession,
|
|
||||||
unlockUser,
|
|
||||||
updateLmsInstructor,
|
|
||||||
type LicensePlan,
|
|
||||||
type Organization,
|
|
||||||
type UserSession
|
|
||||||
} from './operations-api';
|
|
||||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
const emptyCreateForm = {
|
|
||||||
email: '',
|
|
||||||
name: '',
|
|
||||||
password: '',
|
|
||||||
organizationId: '',
|
|
||||||
department: '',
|
|
||||||
position: '',
|
|
||||||
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UserSessionsPage() {
|
export function UserSessionsPage() {
|
||||||
const { toast } = useToast();
|
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
||||||
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
|
||||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
const [lmsRoleSaving, setLmsRoleSaving] = useState(false);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
|
loadUserSessions()
|
||||||
.then((result) => {
|
.then((nextSessions) => {
|
||||||
setSessions(result.items);
|
setSessions(nextSessions);
|
||||||
setTotal(result.total);
|
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -66,19 +31,7 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
Promise.all([
|
}, []);
|
||||||
loadOrganizations().catch(() => []),
|
|
||||||
loadLicensePlans().catch(() => [])
|
|
||||||
]).then(([nextOrganizations, nextLicensePlans]) => {
|
|
||||||
setOrganizations(nextOrganizations);
|
|
||||||
setLicensePlans(nextLicensePlans);
|
|
||||||
setCreateForm((current) => ({
|
|
||||||
...current,
|
|
||||||
organizationId: current.organizationId || '',
|
|
||||||
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}, [page, keyword, filterValues.state]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuSession) return;
|
if (!menuSession) return;
|
||||||
@@ -96,11 +49,11 @@ export function UserSessionsPage() {
|
|||||||
};
|
};
|
||||||
}, [menuSession]);
|
}, [menuSession]);
|
||||||
|
|
||||||
const rows = sessions;
|
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'user',
|
key: 'user',
|
||||||
header: '사용자',
|
header: '사용자',
|
||||||
@@ -118,7 +71,7 @@ export function UserSessionsPage() {
|
|||||||
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||||
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||||
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||||
{ key: 'state', header: '상태', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
{ key: 'state', header: '상태', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -147,78 +100,17 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||||
],
|
],
|
||||||
[]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
setCreateForm({
|
|
||||||
...emptyCreateForm,
|
|
||||||
organizationId: '',
|
|
||||||
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
|
||||||
});
|
|
||||||
setCreateOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitCreateUser = () => {
|
|
||||||
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
|
|
||||||
|
|
||||||
setCreateSaving(true);
|
|
||||||
createManagedUser({
|
|
||||||
email: createForm.email.trim(),
|
|
||||||
name: createForm.name.trim(),
|
|
||||||
password: createForm.password,
|
|
||||||
organizationId: createForm.organizationId || null,
|
|
||||||
department: createForm.department.trim() || null,
|
|
||||||
position: createForm.position.trim() || null,
|
|
||||||
licenseCode: createForm.licenseCode
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
|
|
||||||
setCreateOpen(false);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setCreateSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLmsInstructor = (isInstructor: boolean) => {
|
|
||||||
if (!detailSession) return;
|
|
||||||
|
|
||||||
setLmsRoleSaving(true);
|
|
||||||
updateLmsInstructor(detailSession.userId, isInstructor)
|
|
||||||
.then(() => {
|
|
||||||
setDetailSession((current) => current ? { ...current, isLmsInstructor: isInstructor } : current);
|
|
||||||
setSessions((current) => current.map((session) => (
|
|
||||||
session.userId === detailSession.userId ? { ...session, isLmsInstructor: isInstructor } : session
|
|
||||||
)));
|
|
||||||
toast({ type: 'success', title: 'LMS 역할 변경', message: isInstructor ? '강사 권한을 부여했습니다.' : '강사 권한을 해제했습니다.' });
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: 'LMS 역할 변경 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setLmsRoleSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
||||||
eyebrow="운영"
|
|
||||||
title="사용자 관리"
|
|
||||||
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
|
||||||
actions={
|
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
사용자 데이터를 불러오지 못했습니다.
|
사용자/세션 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
@@ -232,21 +124,7 @@ export function UserSessionsPage() {
|
|||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={900}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자가 없습니다."
|
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||||
serverPagination={{
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
onSearchChange: (value) => {
|
|
||||||
setKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
},
|
|
||||||
filterValues,
|
|
||||||
onFilterChange: (key, value) => {
|
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{menuSession ? (() => {
|
{menuSession ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuSession.id);
|
const row = rows.find((item) => item.id === menuSession.id);
|
||||||
@@ -309,20 +187,8 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<StatusBadge status={detailSession.state} />
|
<StatusBadge status={detailSession.state} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
<div className="grid gap-3 text-sm">
|
||||||
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
||||||
<label className="block min-w-0">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">LMS</span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={detailSession.isLmsInstructor ? 'INSTRUCTOR' : 'STUDENT'}
|
|
||||||
disabled={lmsRoleSaving}
|
|
||||||
onChange={(event) => setLmsInstructor(event.target.value === 'INSTRUCTOR')}
|
|
||||||
>
|
|
||||||
<option value="STUDENT">수강생</option>
|
|
||||||
<option value="INSTRUCTOR">강사</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
||||||
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||||
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||||
@@ -336,96 +202,6 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
open={createOpen}
|
|
||||||
title="사용자 추가"
|
|
||||||
onClose={() => {
|
|
||||||
if (!createSaving) setCreateOpen(false);
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
|
|
||||||
<Button
|
|
||||||
onClick={submitCreateUser}
|
|
||||||
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
|
|
||||||
>
|
|
||||||
{createSaving ? '등록 중' : '등록'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.email}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.name}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.password}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">조직</span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.organizationId}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="">없음</option>
|
|
||||||
{organizations.map((organization) => (
|
|
||||||
<option key={organization.id} value={organization.id}>{organization.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">부서</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.department}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, department: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">직책</span>
|
|
||||||
<input
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.position}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={createForm.licenseCode}
|
|
||||||
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
|
|
||||||
>
|
|
||||||
{licensePlans.map((plan) => (
|
|
||||||
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
|
||||||
))}
|
|
||||||
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
|
|||||||
|
|
||||||
export function statusTone(status?: string | null): BadgeTone {
|
export function statusTone(status?: string | null): BadgeTone {
|
||||||
if (!status) return 'neutral';
|
if (!status) return 'neutral';
|
||||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', '유효토큰', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||||
if (['없음', '세션 없음'].includes(status)) return 'neutral';
|
if (['없음'].includes(status)) return 'neutral';
|
||||||
if (['런처'].includes(status)) return 'violet';
|
if (['런처'].includes(status)) return 'violet';
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
export type PageParams = {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
keyword?: string;
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PagedResponse<T> = {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function pageQuery(params?: PageParams) {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
Object.entries(params ?? {}).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== '') searchParams.set(key, String(value));
|
|
||||||
});
|
|
||||||
const query = searchParams.toString();
|
|
||||||
return query ? `?${query}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminSession = {
|
export type AdminSession = {
|
||||||
id?: string;
|
id?: string;
|
||||||
revokedAt?: string | null;
|
revokedAt?: string | null;
|
||||||
@@ -36,10 +13,8 @@ export type AdminSshKey = {
|
|||||||
|
|
||||||
export type AdminAccount = {
|
export type AdminAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
no?: number;
|
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl?: string | null;
|
|
||||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
@@ -70,7 +45,6 @@ export type UserSession = {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
lastSeen?: string | null;
|
lastSeen?: string | null;
|
||||||
state: string;
|
state: string;
|
||||||
isLmsInstructor?: boolean;
|
|
||||||
primaryAffiliation?: {
|
primaryAffiliation?: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -89,13 +63,6 @@ export type Organization = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
defaultLicenseCode?: string | null;
|
|
||||||
defaultLicenseName?: string | null;
|
|
||||||
instructorInviteCode: string;
|
|
||||||
createdById?: string | null;
|
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,61 +78,22 @@ export type LauncherNotice = {
|
|||||||
endsAt?: string | null;
|
endsAt?: string | null;
|
||||||
priority: number;
|
priority: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherTerm = {
|
export async function loadAdmins() {
|
||||||
id: string;
|
|
||||||
no?: number;
|
|
||||||
key: string;
|
|
||||||
body: string;
|
|
||||||
isRequired: boolean;
|
|
||||||
createdById?: string | null;
|
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ManualDocument = {
|
|
||||||
id: string;
|
|
||||||
no?: number;
|
|
||||||
appKey: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
isPublished: boolean;
|
|
||||||
sortOrder: number;
|
|
||||||
createdById?: string | null;
|
|
||||||
createdByName?: string | null;
|
|
||||||
createdByEmail?: string | null;
|
|
||||||
createdByAvatarUrl?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
|
||||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||||
appPermissions?: Array<string | { appId: string }>;
|
appPermissions?: Array<string | { appId: string }>;
|
||||||
};
|
};
|
||||||
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[]; total?: number; page?: number; limit?: number }>(`/admins${pageQuery(params)}`);
|
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[] }>('/admins');
|
||||||
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
||||||
const items = admins.map((admin) => ({
|
return admins.map((admin) => ({
|
||||||
...admin,
|
...admin,
|
||||||
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
||||||
typeof permission === 'string' ? permission : permission.appId
|
typeof permission === 'string' ? permission : permission.appId
|
||||||
))
|
))
|
||||||
}));
|
}));
|
||||||
if (Array.isArray(response)) return { items, total: items.length, page: params?.page ?? 1, limit: params?.limit ?? items.length };
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAdmins() {
|
|
||||||
return (await loadAdminsPage({ limit: 1000 })).items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAdminRoles() {
|
export function loadAdminRoles() {
|
||||||
@@ -207,7 +135,7 @@ export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
|||||||
|
|
||||||
export function createAdminSshKey(
|
export function createAdminSshKey(
|
||||||
adminId: string,
|
adminId: string,
|
||||||
payload: { label?: string; publicKey: string }
|
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
||||||
) {
|
) {
|
||||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -233,18 +161,13 @@ export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> {
|
export async function loadUserSessions() {
|
||||||
const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`);
|
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
||||||
const items = (response.sessions ?? []).map((session) => ({
|
return (response.sessions ?? []).map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
session: session.sessionId ? session.session : '-',
|
session: session.sessionId ? session.session : '-',
|
||||||
state: session.state
|
state: session.state === '세션 없음' ? '없음' : session.state
|
||||||
}));
|
}));
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadUserSessions() {
|
|
||||||
return (await loadUserSessionsPage({ limit: 1000 })).items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lockUser(userId: string) {
|
export function lockUser(userId: string) {
|
||||||
@@ -259,42 +182,15 @@ export function unlockUser(userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLmsInstructor(userId: string, isInstructor: boolean) {
|
|
||||||
return apiRequest<{ userId: string; isInstructor: boolean }>(`/users/${userId}/lms-instructor`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ isInstructor })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function terminateUserSession(sessionId: string) {
|
export function terminateUserSession(sessionId: string) {
|
||||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createManagedUser(payload: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
name: string;
|
|
||||||
organizationId?: string | null;
|
|
||||||
department?: string | null;
|
|
||||||
position?: string | null;
|
|
||||||
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ user: { id: string; email: string; name?: string | null } }>('/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadOrganizationsPage(params?: PageParams & { type?: string }): Promise<PagedResponse<Organization>> {
|
|
||||||
const response = await apiRequest<{ organizations?: Organization[]; total?: number; page?: number; limit?: number }>(`/organizations${pageQuery(params)}`);
|
|
||||||
const items = response.organizations ?? [];
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadOrganizations() {
|
export async function loadOrganizations() {
|
||||||
return (await loadOrganizationsPage({ limit: 1000 })).items;
|
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||||
|
return response.organizations ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
||||||
@@ -317,21 +213,9 @@ export function deleteOrganization(organizationId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateOrganizationLicense(organizationId: string, licenseCode: string | null) {
|
|
||||||
return apiRequest<{ organization: Organization; updatedUserCount: number }>(`/organizations/${organizationId}/license`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ licenseCode })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadLauncherNoticesPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<LauncherNotice>> {
|
|
||||||
const response = await apiRequest<{ notices?: LauncherNotice[]; total?: number; page?: number; limit?: number }>(`/notices${pageQuery(params)}`);
|
|
||||||
const items = response.notices ?? [];
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadLauncherNotices() {
|
export async function loadLauncherNotices() {
|
||||||
return (await loadLauncherNoticesPage({ limit: 1000 })).items;
|
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||||
|
return response.notices ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLauncherNotice(payload: {
|
export function createLauncherNotice(payload: {
|
||||||
@@ -375,61 +259,6 @@ export function deleteLauncherNotice(noticeId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTermsPage(params?: PageParams & { key?: string }): Promise<PagedResponse<LauncherTerm>> {
|
|
||||||
const response = await apiRequest<{ terms?: LauncherTerm[]; total?: number; page?: number; limit?: number }>(`/terms${pageQuery(params)}`);
|
|
||||||
const items = response.terms ?? [];
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTerm(payload: {
|
|
||||||
key: string;
|
|
||||||
body: string;
|
|
||||||
isRequired?: boolean;
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ term: LauncherTerm }>('/terms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTerm(
|
|
||||||
termId: string,
|
|
||||||
payload: Partial<{
|
|
||||||
key: string;
|
|
||||||
body: string;
|
|
||||||
isRequired: boolean;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
return apiRequest<{ term: LauncherTerm }>(`/terms/${termId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTerm(termId: string) {
|
|
||||||
return apiRequest<{ ok: boolean }>(`/terms/${termId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadManualDocumentsPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<ManualDocument>> {
|
|
||||||
const response = await apiRequest<{ documents?: ManualDocument[]; total?: number; page?: number; limit?: number }>(`/manuals${pageQuery(params)}`);
|
|
||||||
const items = response.documents ?? [];
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createManualDocument(payload: Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>) {
|
|
||||||
return apiRequest<{ document: ManualDocument }>('/manuals', { method: 'POST', body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateManualDocument(id: string, payload: Partial<Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>>) {
|
|
||||||
return apiRequest<{ document: ManualDocument }>(`/manuals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteManualDocument(id: string) {
|
|
||||||
return apiRequest<{ ok: boolean }>(`/manuals/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LicensePlan = {
|
export type LicensePlan = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -475,19 +304,6 @@ export async function loadLicensePlans() {
|
|||||||
return response.plans ?? [];
|
return response.plans ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLicensePlan(licenseCode: string, payload: {
|
|
||||||
name?: string;
|
|
||||||
description?: string | null;
|
|
||||||
defaultDurationDays?: number | null;
|
|
||||||
isActive?: boolean;
|
|
||||||
permissions?: Array<{ appKey: string; canLaunch: boolean }>;
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ plan: LicensePlan; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||||
@@ -497,27 +313,6 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
|
|||||||
return response.users ?? [];
|
return response.users ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLicenseAssignments(params?: PageParams & { licenseCode?: string; status?: string }): Promise<PagedResponse<LicenseAssignment>> {
|
|
||||||
const response = await apiRequest<{ assignments?: LicenseAssignment[]; total?: number; page?: number; limit?: number }>(`/licenses/assignments${pageQuery(params)}`);
|
|
||||||
const items = response.assignments ?? [];
|
|
||||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LicenseAssignment = {
|
|
||||||
id: string;
|
|
||||||
no?: number;
|
|
||||||
userId: string;
|
|
||||||
user: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
code: string;
|
|
||||||
licenseName: string;
|
|
||||||
startsAt: string;
|
|
||||||
expiresAt?: string | null;
|
|
||||||
assignedBy?: string | null;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function assignUserLicense(userId: string, payload: {
|
export function assignUserLicense(userId: string, payload: {
|
||||||
licenseCode: string;
|
licenseCode: string;
|
||||||
startsAt?: string;
|
startsAt?: string;
|
||||||
@@ -559,33 +354,6 @@ export type LauncherUserAppPermission = {
|
|||||||
assignedAt?: string | null;
|
assignedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LicenseAppPermission = {
|
|
||||||
appId: string;
|
|
||||||
appKey: string;
|
|
||||||
displayName: string;
|
|
||||||
description?: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
isEnabled: boolean;
|
|
||||||
policyId?: string | null;
|
|
||||||
canView: boolean;
|
|
||||||
canLaunch: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loadLicenseAppPermissions(licenseCode: string) {
|
|
||||||
const response = await apiRequest<{ permissions?: LicenseAppPermission[] }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`);
|
|
||||||
return response.permissions ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateLicenseAppPermissions(licenseCode: string, permissions: Array<{
|
|
||||||
appKey: string;
|
|
||||||
canLaunch: boolean;
|
|
||||||
}>) {
|
|
||||||
return apiRequest<{ permissions?: LicenseAppPermission[]; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ permissions })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadLauncherUserAppPermissions(userId: string) {
|
export async function loadLauncherUserAppPermissions(userId: string) {
|
||||||
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
||||||
return response.permissions ?? [];
|
return response.permissions ?? [];
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
createRuntimeProfile,
|
createRuntimeProfile,
|
||||||
createRuntimeVariable,
|
createRuntimeVariable,
|
||||||
deleteRuntimeIngressRule,
|
deleteRuntimeIngressRule,
|
||||||
deleteRuntimeProfile,
|
|
||||||
deleteRuntimeVariable,
|
deleteRuntimeVariable,
|
||||||
loadApps,
|
loadApps,
|
||||||
loadRuntimeProfiles,
|
loadRuntimeProfiles,
|
||||||
@@ -81,7 +80,6 @@ export function RuntimeConfigPage() {
|
|||||||
const [actionKey, setActionKey] = useState('');
|
const [actionKey, setActionKey] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||||
const [deleteProfileTarget, setDeleteProfileTarget] = useState<RuntimeEnvProfile | null>(null);
|
|
||||||
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
|
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
|
||||||
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
|
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
@@ -118,7 +116,6 @@ export function RuntimeConfigPage() {
|
|||||||
profile.environment === profileEnvironment &&
|
profile.environment === profileEnvironment &&
|
||||||
profile.namespace.trim() === profileNamespace.trim()
|
profile.namespace.trim() === profileNamespace.trim()
|
||||||
));
|
));
|
||||||
const showDuplicateProfileWarning = Boolean(profileName.trim() && duplicateProfile);
|
|
||||||
|
|
||||||
const refreshResources = (targetNamespace = namespace) => {
|
const refreshResources = (targetNamespace = namespace) => {
|
||||||
if (!targetNamespace.trim()) {
|
if (!targetNamespace.trim()) {
|
||||||
@@ -238,23 +235,6 @@ export function RuntimeConfigPage() {
|
|||||||
.catch((createError: Error) => setError(createError.message));
|
.catch((createError: Error) => setError(createError.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeProfile = () => {
|
|
||||||
if (!deleteProfileTarget) return;
|
|
||||||
const target = deleteProfileTarget;
|
|
||||||
setActionKey(`profile-delete-${target.id}`);
|
|
||||||
deleteRuntimeProfile(target.id)
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', title: '프로필 삭제', message: `${target.name} 프로필을 삭제했습니다.` });
|
|
||||||
setDeleteProfileTarget(null);
|
|
||||||
return refreshProfiles(appId);
|
|
||||||
})
|
|
||||||
.catch((deleteError: Error) => {
|
|
||||||
setError(deleteError.message);
|
|
||||||
toast({ type: 'error', title: '프로필 삭제 실패', message: deleteError.message });
|
|
||||||
})
|
|
||||||
.finally(() => setActionKey(''));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addVariable = () => {
|
const addVariable = () => {
|
||||||
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
||||||
const profile = selectedProfile;
|
const profile = selectedProfile;
|
||||||
@@ -387,13 +367,6 @@ export function RuntimeConfigPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openProfileModal = () => {
|
|
||||||
setProfileName('');
|
|
||||||
setProfileEnvironment('dev');
|
|
||||||
setProfileNamespace(selectedApp?.namespace ?? '');
|
|
||||||
setProfileModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -401,7 +374,7 @@ export function RuntimeConfigPage() {
|
|||||||
title="실행 설정"
|
title="실행 설정"
|
||||||
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
||||||
actions={(
|
actions={(
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openProfileModal} disabled={!appId}>
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
프로필 생성
|
프로필 생성
|
||||||
</Button>
|
</Button>
|
||||||
@@ -441,15 +414,15 @@ export function RuntimeConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -502,19 +475,6 @@ export function RuntimeConfigPage() {
|
|||||||
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 shrink-0 self-end px-0 text-rose-300 hover:text-rose-200 lg:self-center"
|
|
||||||
title="프로필 삭제"
|
|
||||||
aria-label={`${profile.name} 프로필 삭제`}
|
|
||||||
disabled={Boolean(actionKey)}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setDeleteProfileTarget(profile);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -739,24 +699,24 @@ export function RuntimeConfigPage() {
|
|||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
||||||
<Button disabled={!profileName.trim() || !profileNamespace.trim() || showDuplicateProfileWarning} onClick={addProfile}>생성</Button>
|
<Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">프로필명 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">프로필명</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">환경 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">환경</span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
||||||
<option value="dev">개발</option>
|
<option value="dev">개발</option>
|
||||||
<option value="prod">운영</option>
|
<option value="prod">운영</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
|
||||||
<input
|
<input
|
||||||
list="runtime-namespace-options"
|
list="runtime-namespace-options"
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
@@ -768,36 +728,13 @@ export function RuntimeConfigPage() {
|
|||||||
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
||||||
</datalist>
|
</datalist>
|
||||||
</label>
|
</label>
|
||||||
{showDuplicateProfileWarning ? (
|
{duplicateProfile ? (
|
||||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
open={Boolean(deleteProfileTarget)}
|
|
||||||
title="프로필 삭제"
|
|
||||||
onClose={() => {
|
|
||||||
if (!actionKey) setDeleteProfileTarget(null);
|
|
||||||
}}
|
|
||||||
footer={(
|
|
||||||
<>
|
|
||||||
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteProfileTarget(null)}>취소</Button>
|
|
||||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={Boolean(actionKey)} onClick={removeProfile}>
|
|
||||||
{actionKey.startsWith('profile-delete-') ? '삭제 중' : '삭제'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold text-foreground">{deleteProfileTarget?.name ?? '-'}</span>
|
|
||||||
{' '}프로필을 삭제합니다.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">프로필에 등록된 설정값과 접속 설정도 함께 삭제됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(deleteVariableTarget)}
|
open={Boolean(deleteVariableTarget)}
|
||||||
title="설정값 삭제"
|
title="설정값 삭제"
|
||||||
|
|||||||
@@ -1,191 +1,86 @@
|
|||||||
import { MoreHorizontal, RotateCcw } from 'lucide-react';
|
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
|
||||||
import { Badge, Button, Modal } from '../../components/ui';
|
|
||||||
import { apiRequest } from '../../lib/api';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api';
|
import { agentDevices } from '../../data/mock';
|
||||||
|
|
||||||
type HeadscaleNode = {
|
function getTone(status: string) {
|
||||||
id: string;
|
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
||||||
name: string;
|
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
||||||
ip: string;
|
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
||||||
ips?: string[];
|
if (['검수', '동기화'].includes(status)) return 'blue';
|
||||||
role: string;
|
return 'neutral';
|
||||||
user: string;
|
|
||||||
routes: string;
|
|
||||||
lastSeen: string | null;
|
|
||||||
status: string;
|
|
||||||
online?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
|
||||||
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number };
|
|
||||||
|
|
||||||
type DeviceKind = 'agent' | 'launcher' | 'unknown';
|
|
||||||
|
|
||||||
function normalize(value?: string | null) {
|
|
||||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDevice(node: HeadscaleNode, devices: PlatformDevice[]) {
|
|
||||||
const nodeValues = [node.name, node.user].map(normalize).filter(Boolean);
|
|
||||||
return devices.find((device) => {
|
|
||||||
const deviceValues = [device.hostname, device.tailscaleHostname, device.accountEmail].map(normalize).filter(Boolean);
|
|
||||||
return nodeValues.some((nodeValue) => deviceValues.some((deviceValue) => nodeValue.includes(deviceValue) || deviceValue.includes(nodeValue)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findControllers(node: HeadscaleNode, rules: AclRule[]) {
|
|
||||||
const destinations = new Set([node.ip, ...(node.ips ?? [])].filter(Boolean).map((ip) => `${ip}:*`));
|
|
||||||
return [...new Set(rules
|
|
||||||
.filter((rule) => rule.action === 'accept' && rule.destinations.some((destination) => destinations.has(destination)))
|
|
||||||
.flatMap((rule) => rule.sources))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceKind(row: PlatformRow): DeviceKind {
|
|
||||||
if (!row.device) return 'unknown';
|
|
||||||
return row.device.agentVersion || row.device.agentLastSeenAt ? 'agent' : 'launcher';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value?: string | null) {
|
|
||||||
if (!value) return '-';
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ko-KR', { hour12: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeDeviceControlPage() {
|
export function RuntimeDeviceControlPage() {
|
||||||
const [rows, setRows] = useState<PlatformRow[]>([]);
|
return (
|
||||||
const [loading, setLoading] = useState(true);
|
<>
|
||||||
const [selected, setSelected] = useState<PlatformRow | null>(null);
|
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
const refresh = useCallback(() => {
|
<CardHeader
|
||||||
setLoading(true);
|
title="장비 목록"
|
||||||
Promise.all([
|
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||||
apiRequest<{ nodes?: HeadscaleNode[] }>('/tailscale/nodes'),
|
action={
|
||||||
apiRequest<{ policy?: { acls?: AclRule[] } }>('/tailscale/acl'),
|
<div className="flex gap-2">
|
||||||
loadPlatformDevices(),
|
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||||
])
|
<SlidersHorizontal className="size-4" />
|
||||||
.then(([nodeResponse, aclResponse, devices]) => {
|
상태 필터
|
||||||
const rules = aclResponse.policy?.acls ?? [];
|
</Button>
|
||||||
const nodes = nodeResponse.nodes ?? [];
|
<Button className="h-8 px-3 text-[13px]">
|
||||||
setRows(nodes.map((node, index) => ({
|
<Activity className="size-4" />
|
||||||
...node,
|
상태 갱신
|
||||||
no: nodes.length - index,
|
</Button>
|
||||||
device: findDevice(node, devices),
|
|
||||||
controllers: findControllers(node, rules),
|
|
||||||
})));
|
|
||||||
})
|
|
||||||
.catch(() => setRows([]))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
|
||||||
() => [
|
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
|
||||||
{
|
|
||||||
key: 'platform',
|
|
||||||
header: '플랫폼',
|
|
||||||
flexible: true,
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold" title={row.name}>{row.name}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.user}>{row.user || '-'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'connection',
|
|
||||||
header: '연결 대상',
|
|
||||||
width: 180,
|
|
||||||
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deviceKind',
|
|
||||||
header: '유형',
|
|
||||||
width: 150,
|
|
||||||
cell: (row) => {
|
|
||||||
const kind = getDeviceKind(row);
|
|
||||||
if (kind === 'agent') {
|
|
||||||
return <Badge tone="green">에이전트</Badge>;
|
|
||||||
}
|
}
|
||||||
if (kind === 'launcher') return <Badge tone="neutral">런처 클라이언트</Badge>;
|
/>
|
||||||
return <Badge tone="neutral">미확인</Badge>;
|
<div className="border-b border-border px-5 pb-4">
|
||||||
},
|
<div className="relative max-w-md">
|
||||||
},
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
{
|
<input
|
||||||
key: 'controllers',
|
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
header: '제어 사용자',
|
placeholder="장비명, 계정, 워크스페이스 검색"
|
||||||
width: 280,
|
/>
|
||||||
cell: (row) => (
|
</div>
|
||||||
<span className="block truncate text-muted-foreground" title={row.controllers.length ? row.controllers.join(', ') : row.user || '-'}>
|
</div>
|
||||||
{row.controllers.length ? row.controllers.join(', ') : row.user || '-'}
|
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||||
</span>
|
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||||
),
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
},
|
<tr>
|
||||||
{ key: 'routes', header: '연결 경로', width: 190, cell: (row) => <span className="block truncate text-muted-foreground" title={row.routes || '-'}>{row.routes || '-'}</span> },
|
<th className="px-5 py-3 font-medium">장비</th>
|
||||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <Badge tone={row.online || row.status === '활성' ? 'green' : 'neutral'}>{row.online || row.status === '활성' ? '연결됨' : '오프라인'}</Badge> },
|
<th className="px-5 py-3 font-medium">계정</th>
|
||||||
{
|
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||||
key: 'actions',
|
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||||
header: '',
|
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
<th className="px-5 py-3 font-medium">상태</th>
|
||||||
cell: (row) => (
|
<th className="px-5 py-3 text-right font-medium">명령</th>
|
||||||
<div className="flex justify-end">
|
</tr>
|
||||||
<Button variant="ghost" className="size-8 px-0" title="상세" aria-label="상세" onClick={() => setSelected(row)}>
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{agentDevices.map((device) => (
|
||||||
|
<tr key={device.name} className="hover:bg-muted/20">
|
||||||
|
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||||
|
<td className="px-5 py-3">{device.sshKey}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||||
|
<Button variant="ghost" className="size-8 px-0">
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
</td>
|
||||||
},
|
</tr>
|
||||||
],
|
))}
|
||||||
[]
|
</tbody>
|
||||||
);
|
</table>
|
||||||
|
</div>
|
||||||
return (
|
</Card>
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
eyebrow="플랫폼 관리"
|
|
||||||
title="연결 제어"
|
|
||||||
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다."
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
|
||||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataTableCard
|
|
||||||
title="연결 플랫폼"
|
|
||||||
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다."
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색"
|
|
||||||
getSearchText={(row) => `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`}
|
|
||||||
getRowKey={(row) => row.id || row.name}
|
|
||||||
pageSize={6}
|
|
||||||
minWidth={1100}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage="Headscale에 연결된 플랫폼이 없습니다."
|
|
||||||
/>
|
|
||||||
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}>
|
|
||||||
<dl className="grid grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-4 text-sm">
|
|
||||||
<dt className="text-muted-foreground">플랫폼</dt><dd className="min-w-0 break-all font-medium">{selected?.name}</dd>
|
|
||||||
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd>
|
|
||||||
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">장비 유형</dt><dd>{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">에이전트 버전</dt><dd>{selected?.device?.agentVersion || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">마지막 에이전트 확인</dt><dd>{formatDateTime(selected?.device?.agentLastSeenAt)}</dd>
|
|
||||||
</dl>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,25 +16,6 @@ function eventTone(type?: string) {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventRanges = [
|
|
||||||
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
|
||||||
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
|
||||||
{ label: '전체', value: 'all', milliseconds: null }
|
|
||||||
];
|
|
||||||
|
|
||||||
function eventTime(event: RuntimeEvent) {
|
|
||||||
const value = event.lastTimestamp ?? event.firstTimestamp;
|
|
||||||
if (!value) return 0;
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) ? time : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
|
||||||
if (!range.milliseconds) return true;
|
|
||||||
const time = eventTime(event);
|
|
||||||
return time > 0 && time >= Date.now() - range.milliseconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiKind(kind: string) {
|
function apiKind(kind: string) {
|
||||||
return kind.toLowerCase();
|
return kind.toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -147,7 +128,6 @@ export function RuntimeLogsPage() {
|
|||||||
const [tail, setTail] = useState(100);
|
const [tail, setTail] = useState(100);
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [eventRange, setEventRange] = useState('1h');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -167,9 +147,7 @@ export function RuntimeLogsPage() {
|
|||||||
}, [podMetrics]);
|
}, [podMetrics]);
|
||||||
const loggableResources = resources.filter(isActiveLogTarget);
|
const loggableResources = resources.filter(isActiveLogTarget);
|
||||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
|
||||||
const filteredEvents = events.filter((event) => {
|
const filteredEvents = events.filter((event) => {
|
||||||
if (!isEventInRange(event, selectedEventRange)) return false;
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
if (!normalizedSearch) return true;
|
if (!normalizedSearch) return true;
|
||||||
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||||
@@ -239,26 +217,6 @@ export function RuntimeLogsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== 'events' || !namespace.trim()) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const loadEvents = () => {
|
|
||||||
loadRuntimeEvents(namespace.trim())
|
|
||||||
.then((nextEvents) => {
|
|
||||||
if (!cancelled) setEvents(nextEvents);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEvents();
|
|
||||||
const timer = window.setInterval(loadEvents, 10_000);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [activeTab, namespace]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -405,19 +363,6 @@ export function RuntimeLogsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{eventRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.value}
|
|
||||||
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
|
||||||
className="h-8 px-3 text-[13px]"
|
|
||||||
onClick={() => setEventRange(range.value)}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full lg:w-80">
|
<div className="relative w-full lg:w-80">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -427,7 +372,6 @@ export function RuntimeLogsPage() {
|
|||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ export function createRuntimeProfile(input: { appId: string; name: string; envir
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRuntimeProfile(profileId: string) {
|
|
||||||
return apiRequest<{ deleted: boolean; profileId: string; name: string }>(`/apps/runtime-profiles/${profileId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRuntimeVariable(
|
export function createRuntimeVariable(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
||||||
@@ -66,12 +60,6 @@ export function loadRuntimeResources(namespace: string) {
|
|||||||
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadRuntimeResourceYaml(namespace: string, kind: string, name: string) {
|
|
||||||
return apiRequest<{ namespace: string; kind: string; name: string; yaml: string }>(
|
|
||||||
`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/yaml`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadRuntimePodMetrics(namespace?: string) {
|
export function loadRuntimePodMetrics(namespace?: string) {
|
||||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
import { Activity, AlertTriangle, RotateCcw, Server } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis
|
|
||||||
} from 'recharts';
|
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
|
||||||
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
|
||||||
import { cn } from '../../lib/utils';
|
|
||||||
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource } from '../../types/runtime';
|
|
||||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
||||||
|
|
||||||
type ResourceRow = RuntimeResource & {
|
|
||||||
metric?: RuntimePodMetric;
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartColors = ['#34d399', '#60a5fa', '#fbbf24', '#fb7185', '#a78bfa'];
|
|
||||||
const commonAppMonitoringAppId = '__common_apps__';
|
|
||||||
const commonAppMonitoringApp: ApiApp = {
|
|
||||||
id: commonAppMonitoringAppId,
|
|
||||||
key: 'studio',
|
|
||||||
name: '공통앱',
|
|
||||||
namespace: 'studio-system',
|
|
||||||
status: 'ACTIVE'
|
|
||||||
};
|
|
||||||
const eventRanges = [
|
|
||||||
{ label: '최근 10분', value: '10m', milliseconds: 10 * 60 * 1000 },
|
|
||||||
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
|
||||||
{ label: '6시간', value: '6h', milliseconds: 6 * 60 * 60 * 1000 },
|
|
||||||
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 }
|
|
||||||
];
|
|
||||||
|
|
||||||
function parseCpuMillicores(value?: string) {
|
|
||||||
if (!value) return 0;
|
|
||||||
if (value.endsWith('m')) return Number(value.slice(0, -1)) || 0;
|
|
||||||
return (Number(value) || 0) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMemoryMi(value?: string) {
|
|
||||||
if (!value) return 0;
|
|
||||||
const matched = value.match(/^([0-9.]+)([A-Za-z]+)?$/);
|
|
||||||
if (!matched) return 0;
|
|
||||||
const amount = Number(matched[1]);
|
|
||||||
const unit = matched[2] ?? '';
|
|
||||||
if (!Number.isFinite(amount)) return 0;
|
|
||||||
if (unit === 'Ki') return amount / 1024;
|
|
||||||
if (unit === 'Gi') return amount * 1024;
|
|
||||||
if (unit === 'Ti') return amount * 1024 * 1024;
|
|
||||||
if (unit === 'Mi' || unit === 'M') return amount;
|
|
||||||
return amount / 1024 / 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHealthy(resource: RuntimeResource) {
|
|
||||||
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(resource.status)) return false;
|
|
||||||
if (!resource.ready.includes('/')) return ['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(resource.status);
|
|
||||||
const [ready, total] = resource.ready.split('/').map((value) => Number(value));
|
|
||||||
return Number.isFinite(ready) && Number.isFinite(total) && total > 0 && ready === total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
|
||||||
if (isHealthy({ kind: '', name: '', status, ready: status === 'Running' ? '1/1' : '-', restarts: null, age: '', detail: '' })) return 'green';
|
|
||||||
if (['Pending', 'ContainerCreating'].includes(status)) return 'amber';
|
|
||||||
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(status)) return 'red';
|
|
||||||
return 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCompletedMigration(resource: RuntimeResource) {
|
|
||||||
const normalizedName = resource.name.toLowerCase();
|
|
||||||
const normalizedStatus = resource.status.toLowerCase();
|
|
||||||
return normalizedName.includes('migration') && ['completed', 'succeeded', 'success'].includes(normalizedStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTime(event: RuntimeEvent) {
|
|
||||||
const value = event.lastTimestamp ?? event.firstTimestamp;
|
|
||||||
if (!value) return 0;
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) ? time : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MonitoringPage() {
|
|
||||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
|
||||||
const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
|
|
||||||
const [eventRange, setEventRange] = useState('1h');
|
|
||||||
const [resources, setResources] = useState<ResourceRow[]>([]);
|
|
||||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
|
||||||
|
|
||||||
const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0];
|
|
||||||
const selectedRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[1];
|
|
||||||
const visibleResources = useMemo(() => resources.filter((resource) => !isCompletedMigration(resource)), [resources]);
|
|
||||||
const showInitialSkeleton = loading && resources.length === 0 && events.length === 0;
|
|
||||||
const filteredEvents = useMemo(() => {
|
|
||||||
const threshold = Date.now() - selectedRange.milliseconds;
|
|
||||||
return events.filter((event) => eventTime(event) >= threshold);
|
|
||||||
}, [events, selectedRange.milliseconds]);
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
loadApps()
|
|
||||||
.then(async (nextApps) => {
|
|
||||||
const appItems = [commonAppMonitoringApp, ...nextApps.filter((app) => app.namespace)];
|
|
||||||
const storedAppId = getStoredSelectedAppId();
|
|
||||||
const preferredAppId = selectedAppId || storedAppId;
|
|
||||||
const app = appItems.find((item) => item.id === preferredAppId)
|
|
||||||
?? appItems.find((item) => item.id === storedAppId)
|
|
||||||
?? appItems[0];
|
|
||||||
setApps(appItems);
|
|
||||||
setSelectedAppId(app?.id ?? '');
|
|
||||||
|
|
||||||
if (!app?.namespace) {
|
|
||||||
setResources([]);
|
|
||||||
setEvents([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [runtime, metrics, eventItems] = await Promise.all([
|
|
||||||
loadRuntimeResources(app.namespace),
|
|
||||||
loadRuntimePodMetrics(app.namespace).catch(() => []),
|
|
||||||
loadRuntimeEvents(app.namespace).catch(() => [])
|
|
||||||
]);
|
|
||||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
|
||||||
setResources((runtime.resources ?? []).map((resource) => ({
|
|
||||||
...resource,
|
|
||||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
|
||||||
})));
|
|
||||||
setEvents(eventItems);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedApp?.namespace) return;
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
loadRuntimeResources(selectedApp.namespace),
|
|
||||||
loadRuntimePodMetrics(selectedApp.namespace).catch(() => []),
|
|
||||||
loadRuntimeEvents(selectedApp.namespace).catch(() => [])
|
|
||||||
])
|
|
||||||
.then(([runtime, metrics, eventItems]) => {
|
|
||||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
|
||||||
setResources((runtime.resources ?? []).map((resource) => ({
|
|
||||||
...resource,
|
|
||||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
|
||||||
})));
|
|
||||||
setEvents(eventItems);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [selectedAppId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedApp?.namespace) return;
|
|
||||||
const namespace = selectedApp.namespace;
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
Promise.all([
|
|
||||||
loadRuntimeResources(namespace),
|
|
||||||
loadRuntimePodMetrics(namespace).catch(() => []),
|
|
||||||
loadRuntimeEvents(namespace).catch(() => [])
|
|
||||||
])
|
|
||||||
.then(([runtime, metrics, eventItems]) => {
|
|
||||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
|
||||||
setResources((runtime.resources ?? []).map((resource) => ({
|
|
||||||
...resource,
|
|
||||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
|
||||||
})));
|
|
||||||
setEvents(eventItems);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [selectedApp?.namespace]);
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const runtimeResources = visibleResources.filter((resource) => ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
|
|
||||||
const healthy = runtimeResources.filter(isHealthy).length;
|
|
||||||
const warnings = filteredEvents.filter((event) => event.type === 'Warning').length;
|
|
||||||
const restarts = visibleResources.reduce((sum, resource) => sum + (resource.restarts ?? 0), 0);
|
|
||||||
return { total: runtimeResources.length, healthy, warnings, restarts };
|
|
||||||
}, [filteredEvents, visibleResources]);
|
|
||||||
|
|
||||||
const podMetrics = useMemo(() => visibleResources
|
|
||||||
.filter((resource) => resource.kind === 'Pod' && resource.metric)
|
|
||||||
.map((resource) => ({
|
|
||||||
name: resource.name.replace(`${selectedApp?.key?.toLowerCase() ?? ''}-`, ''),
|
|
||||||
cpu: parseCpuMillicores(resource.metric?.cpu),
|
|
||||||
memory: Math.round(parseMemoryMi(resource.metric?.memory))
|
|
||||||
})), [visibleResources, selectedApp?.key]);
|
|
||||||
|
|
||||||
const statusData = useMemo(() => {
|
|
||||||
const byStatus = new Map<string, number>();
|
|
||||||
visibleResources.forEach((resource) => byStatus.set(resource.status || '-', (byStatus.get(resource.status || '-') ?? 0) + 1));
|
|
||||||
return [...byStatus.entries()].map(([name, value]) => ({ name, value }));
|
|
||||||
}, [visibleResources]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
eyebrow="시스템"
|
|
||||||
title="모니터링"
|
|
||||||
description="선택한 앱 서비스의 런타임 상태, 리소스 사용량, 최근 이벤트를 확인합니다."
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
|
||||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="mb-4 p-4">
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(260px,420px)_1fr] lg:items-end">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">앱</span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={selectedAppId}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextAppId = event.target.value;
|
|
||||||
setSelectedAppId(nextAppId);
|
|
||||||
if (nextAppId !== commonAppMonitoringAppId) setStoredSelectedAppId(nextAppId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{apps.map((app) => (
|
|
||||||
<option key={app.id} value={app.id}>{app.name} · {app.namespace}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">검색 기간</p>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{eventRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.value}
|
|
||||||
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
|
||||||
className="h-9 px-3 text-[13px]"
|
|
||||||
onClick={() => setEventRange(range.value)}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{showInitialSkeleton ? (
|
|
||||||
Array.from({ length: 4 }).map((_, index) => <MetricCardSkeleton key={index} />)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} />
|
|
||||||
<MetricCard icon={Activity} label="Pod" value={String(visibleResources.filter((resource) => resource.kind === 'Pod').length)} hint="현재 기준" tone="blue" />
|
|
||||||
<MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint={`${selectedRange.label} 기준`} tone={summary.warnings > 0 ? 'amber' : 'green'} />
|
|
||||||
<MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="현재 Pod 누적" tone={summary.restarts > 0 ? 'amber' : 'green'} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
|
|
||||||
<Card className="min-w-0 overflow-hidden">
|
|
||||||
<CardHeader title="Pod 리소스" description="선택한 앱 네임스페이스의 Pod CPU/Memory 사용량입니다." />
|
|
||||||
<div className="h-80 p-5">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={podMetrics}>
|
|
||||||
<CartesianGrid stroke="rgba(148,163,184,0.16)" vertical={false} />
|
|
||||||
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
||||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
|
||||||
<Bar dataKey="cpu" name="CPU(m)" fill="#60a5fa" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="memory" name="Memory(Mi)" fill="#34d399" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden">
|
|
||||||
<CardHeader title="상태 분포" description="런타임 리소스 상태별 개수입니다." />
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="h-60">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie data={statusData} dataKey="value" nameKey="name" innerRadius={58} outerRadius={92} paddingAngle={3}>
|
|
||||||
{statusData.map((entry, index) => <Cell key={entry.name} fill={chartColors[index % chartColors.length]} />)}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
|
||||||
{statusData.map((entry, index) => (
|
|
||||||
<div key={entry.name} className="flex items-center justify-between gap-3 rounded-md bg-muted/30 px-3 py-2 text-xs">
|
|
||||||
<span className="inline-flex min-w-0 items-center gap-2">
|
|
||||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index % chartColors.length] }} />
|
|
||||||
<span className="truncate text-muted-foreground">{entry.name}</span>
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-foreground">{entry.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." />
|
|
||||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
|
||||||
{showInitialSkeleton ? (
|
|
||||||
<ListSkeleton rows={6} />
|
|
||||||
) : visibleResources.map((resource) => (
|
|
||||||
<div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(resource.status))} />
|
|
||||||
<p className="truncate font-medium">{resource.kind}/{resource.name}</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{resource.ready || '-'} · 재시작 {resource.restarts ?? 0}</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone={statusTone(resource.status)}>{resource.status || '-'}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<CardHeader title="이벤트 목록" description="선택한 기간의 Warning 이벤트를 우선 확인합니다." />
|
|
||||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
|
||||||
{showInitialSkeleton ? (
|
|
||||||
<ListSkeleton rows={5} />
|
|
||||||
) : filteredEvents.slice(0, 12).map((event, index) => (
|
|
||||||
<div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge>
|
|
||||||
<p className="truncate font-medium">{event.reason ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!showInitialSkeleton && !filteredEvents.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">선택 기간에 표시할 이벤트가 없습니다.</div> : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoadingModal open={loadingVisible} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkeletonBlock({ className }: { className: string }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'relative overflow-hidden rounded-md bg-white/15',
|
|
||||||
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite]',
|
|
||||||
'before:bg-gradient-to-r before:from-transparent before:via-white/35 before:to-transparent',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card className="p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<SkeletonBlock className="h-4 w-20" />
|
|
||||||
<SkeletonBlock className="size-4 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-end justify-between gap-3">
|
|
||||||
<SkeletonBlock className="h-9 w-24" />
|
|
||||||
<SkeletonBlock className="h-6 w-12" />
|
|
||||||
</div>
|
|
||||||
<SkeletonBlock className="mt-3 h-3 w-36" />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListSkeleton({ rows }: { rows: number }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: rows }).map((_, index) => (
|
|
||||||
<div key={index} className="px-5 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SkeletonBlock className="size-2.5 rounded-full" />
|
|
||||||
<SkeletonBlock className="h-4 w-3/5" />
|
|
||||||
</div>
|
|
||||||
<SkeletonBlock className="mt-2 h-3 w-2/5" />
|
|
||||||
</div>
|
|
||||||
<SkeletonBlock className="h-6 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: typeof Server; label: string; value: string; hint: string; tone: 'green' | 'amber' | 'red' | 'blue' | 'neutral' }) {
|
|
||||||
return (
|
|
||||||
<Card className="p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">{label}</p>
|
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-end justify-between gap-3">
|
|
||||||
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
|
||||||
<Badge tone={tone}>{tone === 'green' ? '정상' : tone === 'amber' ? '주의' : tone === 'red' ? '장애' : '정보'}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{hint}</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,14 +21,9 @@ export type BuildJob = {
|
|||||||
branch: string;
|
branch: string;
|
||||||
commitSha?: string | null;
|
commitSha?: string | null;
|
||||||
artifact?: string | null;
|
artifact?: string | null;
|
||||||
artifactSha256?: string | null;
|
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
requestedBy?: string | null;
|
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -45,10 +40,6 @@ export type AppRelease = {
|
|||||||
imageTags?: unknown;
|
imageTags?: unknown;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
requestedBy?: string | null;
|
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
app: ApiApp;
|
app: ApiApp;
|
||||||
@@ -62,9 +53,6 @@ export type DeploymentJob = {
|
|||||||
namespace: string;
|
namespace: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -81,9 +69,6 @@ export type RuntimeControlJob = {
|
|||||||
action: string;
|
action: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
requestedByAvatarUrl?: string | null;
|
|
||||||
request?: Record<string, unknown> | null;
|
request?: Record<string, unknown> | null;
|
||||||
result?: Record<string, unknown> | null;
|
result?: Record<string, unknown> | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export type ApiAppComponent = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
nodePort?: number;
|
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
buildJobs?: Array<{
|
buildJobs?: Array<{
|
||||||
@@ -41,7 +40,6 @@ export type ApiApp = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
namespace?: string | null;
|
namespace?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
launcherLinked?: boolean;
|
|
||||||
components?: ApiAppComponent[];
|
components?: ApiAppComponent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user