This commit is contained in:
2026-01-06 21:44:36 +09:00
parent ceec1ad7a9
commit 716cf63f73
98 changed files with 6997 additions and 538 deletions

View File

@@ -65,9 +65,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"cpu": [
"ppc64"
],
@@ -82,9 +82,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"cpu": [
"arm"
],
@@ -99,9 +99,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"cpu": [
"arm64"
],
@@ -116,9 +116,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"cpu": [
"x64"
],
@@ -133,9 +133,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"cpu": [
"arm64"
],
@@ -150,9 +150,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"cpu": [
"x64"
],
@@ -167,9 +167,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"cpu": [
"arm64"
],
@@ -184,9 +184,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"cpu": [
"x64"
],
@@ -201,9 +201,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"cpu": [
"arm"
],
@@ -218,9 +218,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"cpu": [
"arm64"
],
@@ -235,9 +235,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"cpu": [
"ia32"
],
@@ -252,9 +252,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"cpu": [
"loong64"
],
@@ -269,9 +269,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"cpu": [
"mips64el"
],
@@ -286,9 +286,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"cpu": [
"ppc64"
],
@@ -303,9 +303,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"cpu": [
"riscv64"
],
@@ -320,9 +320,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"cpu": [
"s390x"
],
@@ -337,9 +337,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"cpu": [
"x64"
],
@@ -354,9 +354,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"cpu": [
"x64"
],
@@ -371,9 +371,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"cpu": [
"x64"
],
@@ -388,9 +388,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"cpu": [
"x64"
],
@@ -405,9 +405,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"cpu": [
"arm64"
],
@@ -422,9 +422,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"cpu": [
"ia32"
],
@@ -439,9 +439,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"cpu": [
"x64"
],
@@ -819,67 +819,67 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.3.tgz",
"integrity": "sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vite": "^5.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz",
"integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"@babel/parser": "^7.23.6",
"@vue/shared": "3.4.15",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz",
"integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-core": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz",
"integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26",
"@babel/parser": "^7.23.6",
"@vue/compiler-core": "3.4.15",
"@vue/compiler-dom": "3.4.15",
"@vue/compiler-ssr": "3.4.15",
"@vue/shared": "3.4.15",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
"magic-string": "^0.30.5",
"postcss": "^8.4.33",
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz",
"integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-dom": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/devtools-api": {
@@ -889,53 +889,52 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz",
"integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.26"
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz",
"integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/reactivity": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz",
"integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/runtime-core": "3.5.26",
"@vue/shared": "3.5.26",
"csstype": "^3.2.3"
"@vue/runtime-core": "3.4.15",
"@vue/shared": "3.4.15",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz",
"integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-ssr": "3.4.15",
"@vue/shared": "3.4.15"
},
"peerDependencies": {
"vue": "3.5.26"
"vue": "3.4.15"
}
},
"node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz",
"integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==",
"license": "MIT"
},
"node_modules/asynckit": {
@@ -945,13 +944,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
"integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -1010,9 +1009,9 @@
}
},
"node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -1067,9 +1066,9 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1080,29 +1079,29 @@
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
}
},
"node_modules/estree-walker": {
@@ -1323,27 +1322,57 @@
"license": "ISC"
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
"vue": "^2.6.14 || ^3.3.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1433,15 +1462,15 @@
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz",
"integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.19.3",
"postcss": "^8.4.32",
"rollup": "^4.2.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -1460,7 +1489,6 @@
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
@@ -1478,9 +1506,6 @@
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
@@ -1493,16 +1518,16 @@
}
},
"node_modules/vue": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz",
"integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
"@vue/runtime-dom": "3.5.26",
"@vue/server-renderer": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-dom": "3.4.15",
"@vue/compiler-sfc": "3.4.15",
"@vue/runtime-dom": "3.4.15",
"@vue/server-renderer": "3.4.15",
"@vue/shared": "3.4.15"
},
"peerDependencies": {
"typescript": "*"
@@ -1513,45 +1538,19 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz",
"integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
"@vue/devtools-api": "^6.5.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
"vue": "^3.2.0"
}
}
}

View File

@@ -9,10 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"axios": "^1.6.5",
"pinia": "^2.1.7"
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",

View File

@@ -20,11 +20,19 @@
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#app {
min-height: 100vh;
background: #f5f5f5;
}
</style>
.header {
background: #2c3e50;

View File

@@ -23,4 +23,129 @@ api.interceptors.response.use(
}
)
// Server API
export const serverApi = {
getAll: () => api.get('/servers'),
getAllActive: () => api.get('/servers/active'),
getById: (id) => api.get(`/servers/${id}`),
create: (data) => api.post('/servers', data),
update: (id, data) => api.put(`/servers/${id}`, data),
delete: (id) => api.delete(`/servers/${id}`),
testConnection: (id) => api.post(`/servers/${id}/test-connection`)
}
// LogPath API
export const logPathApi = {
getByServerId: (serverId) => api.get(`/log-paths/server/${serverId}`),
getActiveByServerId: (serverId) => api.get(`/log-paths/server/${serverId}/active`),
getById: (id) => api.get(`/log-paths/${id}`),
create: (data) => api.post('/log-paths', data),
update: (id, data) => api.put(`/log-paths/${id}`, data),
delete: (id) => api.delete(`/log-paths/${id}`)
}
// Pattern API
export const patternApi = {
getAll: () => api.get('/patterns'),
getAllActive: () => api.get('/patterns/active'),
getById: (id) => api.get(`/patterns/${id}`),
create: (data) => api.post('/patterns', data),
update: (id, data) => api.put(`/patterns/${id}`, data),
delete: (id) => api.delete(`/patterns/${id}`),
test: (regex, sampleText) => api.post('/patterns/test', null, { params: { regex, sampleText } })
}
// Setting API
export const settingApi = {
getAll: () => api.get('/settings'),
getAllAsMap: () => api.get('/settings/map'),
getValue: (key) => api.get(`/settings/${key}`),
save: (data) => api.post('/settings', data),
saveAll: (settings) => api.put('/settings', settings),
delete: (key) => api.delete(`/settings/${key}`)
}
// Scan API
export const scanApi = {
// SSE 기반 스캔 시작 (진행상황 실시간 수신)
startWithProgress: (serverId, onProgress, onComplete, onError) => {
const eventSource = new EventSource(`/api/scan/start/${serverId}`)
eventSource.addEventListener('progress', (event) => {
const progress = JSON.parse(event.data)
onProgress && onProgress(progress)
})
eventSource.addEventListener('complete', (event) => {
const result = JSON.parse(event.data)
onComplete && onComplete(result)
eventSource.close()
})
eventSource.addEventListener('error', (event) => {
if (event.data) {
onError && onError(event.data)
}
eventSource.close()
})
eventSource.onerror = () => {
eventSource.close()
}
return eventSource
},
// SSE 기반 전체 서버 스캔
startAllWithProgress: (onProgress, onComplete, onError) => {
const eventSource = new EventSource('/api/scan/start-all')
eventSource.addEventListener('progress', (event) => {
const progress = JSON.parse(event.data)
onProgress && onProgress(progress)
})
eventSource.addEventListener('complete', (event) => {
const results = JSON.parse(event.data)
onComplete && onComplete(results)
eventSource.close()
})
eventSource.addEventListener('error', (event) => {
if (event.data) {
onError && onError(event.data)
}
eventSource.close()
})
eventSource.onerror = () => {
eventSource.close()
}
return eventSource
},
// 동기 방식 스캔 (간단 실행)
execute: (serverId) => api.post(`/scan/execute/${serverId}`),
// 진행 상황 조회
getProgress: (serverId) => api.get(`/scan/progress/${serverId}`),
// 스캔 이력 조회
getHistory: (serverId) => api.get(`/scan/history/${serverId}`)
}
// ErrorLog API
export const errorLogApi = {
search: (params) => api.get('/error-logs', { params }),
getById: (id) => api.get(`/error-logs/${id}`),
getByServer: (serverId, params) => api.get(`/error-logs/server/${serverId}`, { params })
}
// Export API (Step 5에서 구현 예정)
export const exportApi = {
exportHtml: (params) => api.post('/export/html', params, { responseType: 'blob' }),
exportTxt: (params) => api.post('/export/txt', params, { responseType: 'blob' })
}
export default api

View File

@@ -0,0 +1,56 @@
<template>
<span :class="['badge', `badge-${variant}`]">
<slot>{{ text }}</slot>
</span>
</template>
<script setup>
defineProps({
text: String,
variant: {
type: String,
default: 'default'
// default, critical, error, warn, success, info
}
})
</script>
<style scoped>
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.badge-default {
background: #e9ecef;
color: #495057;
}
.badge-critical {
background: #e74c3c;
color: white;
}
.badge-error {
background: #e74c3c;
color: white;
}
.badge-warn {
background: #f39c12;
color: white;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-info {
background: #3498db;
color: white;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<button
:type="type"
:class="['btn', `btn-${variant}`, { 'btn-sm': size === 'sm', 'btn-lg': size === 'lg' }]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="spinner"></span>
<slot></slot>
</button>
</template>
<script setup>
defineProps({
type: {
type: String,
default: 'button'
},
variant: {
type: String,
default: 'primary'
// primary, secondary, danger, success, warning
},
size: {
type: String,
default: 'md'
// sm, md, lg
},
disabled: Boolean,
loading: Boolean
})
defineEmits(['click'])
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 12px 24px;
font-size: 16px;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #1e8449;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d68910;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="card">
<div v-if="title || $slots.header" class="card-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: String
})
</script>
<style scoped>
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.card-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.card-body {
padding: 20px;
}
.card-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
{{ col.label }}
</th>
<th v-if="$slots.actions" class="actions-col">작업</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="loading-cell">
로딩 ...
</td>
</tr>
<tr v-else-if="!data || data.length === 0">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="empty-cell">
{{ emptyText }}
</td>
</tr>
<tr v-else v-for="(row, idx) in data" :key="row.id || idx" @click="$emit('row-click', row)">
<td v-for="col in columns" :key="col.key">
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ formatValue(row[col.key], col) }}
</slot>
</td>
<td v-if="$slots.actions" class="actions-col">
<slot name="actions" :row="row"></slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true
// { key: 'name', label: '이름', width: '100px', type: 'date' }
},
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
emptyText: {
type: String,
default: '데이터가 없습니다.'
}
})
defineEmits(['row-click'])
const formatValue = (value, col) => {
if (value === null || value === undefined) return '-'
if (col.type === 'date' && value) {
return new Date(value).toLocaleString('ko-KR')
}
if (col.type === 'boolean') {
return value ? 'Y' : 'N'
}
return value
}
</script>
<style scoped>
.data-table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.data-table tbody tr:hover {
background: #f8f9fa;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.actions-col {
width: 120px;
text-align: center;
}
.loading-cell,
.empty-cell {
text-align: center;
color: #6c757d;
padding: 40px !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="form-group">
<label v-if="label" :for="inputId">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<input
v-if="type !== 'textarea' && type !== 'select'"
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<textarea
v-else-if="type === 'textarea'"
:id="inputId"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:rows="rows"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<select
v-else-if="type === 'select'"
:id="inputId"
:value="modelValue"
:disabled="disabled"
class="form-input"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span v-if="error" class="error-text">{{ error }}</span>
<span v-if="hint" class="hint-text">{{ hint }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
label: String,
type: {
type: String,
default: 'text'
},
placeholder: String,
required: Boolean,
disabled: Boolean,
readonly: Boolean,
error: String,
hint: String,
rows: {
type: Number,
default: 3
},
options: {
type: Array,
default: () => []
// [{ value: 'a', label: 'A' }]
}
})
defineEmits(['update:modelValue'])
const inputId = computed(() => `input-${Math.random().toString(36).slice(2, 9)}`)
</script>
<style scoped>
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #333;
}
.required {
color: #e74c3c;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
}
select.form-input {
cursor: pointer;
}
.error-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #e74c3c;
}
.hint-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #6c757d;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="modal" :style="{ width: width }">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="close">&times;</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '500px'
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const close = () => {
emit('update:modelValue', false)
emit('close')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as DataTable } from './DataTable.vue'
export { default as Modal } from './Modal.vue'
export { default as FormInput } from './FormInput.vue'
export { default as Button } from './Button.vue'
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'

View File

@@ -1,61 +1,545 @@
<template>
<div class="dashboard">
<h2>대시보드</h2>
<p>서버 목록과 분석 실행 화면입니다.</p>
<div class="server-list">
<table>
<div class="dashboard-header">
<h2>대시보드</h2>
<div class="header-actions">
<Button
@click="scanAllServers"
:loading="scanningAll"
:disabled="activeServers.length === 0"
>
전체 분석 실행
</Button>
</div>
</div>
<!-- 서버 목록 -->
<div class="server-grid">
<Card v-for="server in servers" :key="server.id" class="server-card">
<template #header>
<div class="server-header">
<div class="server-title">
<Badge :variant="server.active ? 'success' : 'default'" size="sm">
{{ server.active ? '활성' : '비활성' }}
</Badge>
<h4>{{ server.name }}</h4>
</div>
<div class="server-actions">
<Button
size="sm"
@click="scanServer(server)"
:loading="scanningServerId === server.id"
:disabled="!server.active || scanningAll"
>
분석 실행
</Button>
</div>
</div>
</template>
<div class="server-info">
<div class="info-row">
<span class="label">호스트</span>
<span class="value">{{ server.host }}:{{ server.port }}</span>
</div>
<div class="info-row">
<span class="label">마지막 분석</span>
<span class="value">{{ formatDate(server.lastScanAt) }}</span>
</div>
<div class="info-row">
<span class="label">마지막 에러</span>
<span class="value" :class="{ 'has-error': server.lastErrorAt }">
{{ formatDate(server.lastErrorAt) }}
</span>
</div>
</div>
<!-- 진행 상황 -->
<div v-if="progressMap[server.id]" class="progress-section">
<div class="progress-header">
<span class="status-text">{{ getStatusText(progressMap[server.id]) }}</span>
<Badge :variant="progressMap[server.id].status === 'RUNNING' ? 'warn' : 'success'">
{{ progressMap[server.id].status }}
</Badge>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{ width: getProgressPercent(progressMap[server.id]) + '%' }"
></div>
</div>
<div class="progress-details">
<span>파일: {{ progressMap[server.id].scannedFiles }} / {{ progressMap[server.id].totalFiles }}</span>
<span>에러: {{ progressMap[server.id].errorsFound }}</span>
</div>
</div>
</Card>
</div>
<!-- 서버 없음 -->
<Card v-if="servers.length === 0 && !loading" class="empty-card">
<div class="empty-content">
<p>등록된 서버가 없습니다.</p>
<Button @click="$router.push('/servers')">서버 등록하기</Button>
</div>
</Card>
<!-- 최근 에러 -->
<Card v-if="recentErrors.length > 0" class="recent-errors">
<template #header>
<div class="section-header">
<h3>최근 에러</h3>
<Button size="sm" variant="secondary" @click="$router.push('/errors')">전체보기</Button>
</div>
</template>
<table class="error-table">
<thead>
<tr>
<th>서버명</th>
<th>마지막 분석</th>
<th>마지막 에러</th>
<th>액션</th>
<th>시간</th>
<th>서버</th>
<th>심각도</th>
<th>요약</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="empty">등록된 서버가 없습니다.</td>
<tr v-for="error in recentErrors" :key="error.id" @click="showErrorDetail(error)">
<td>{{ formatDate(error.occurredAt) }}</td>
<td>{{ error.serverName }}</td>
<td>
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="summary-cell">{{ truncate(error.summary, 80) }}</td>
</tr>
</tbody>
</table>
</div>
</Card>
<!-- 에러 상세 모달 -->
<Modal v-model="showErrorModal" title="에러 상세" width="800px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>파일</label>
<span>{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showErrorModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Card, Button, Badge, Modal } from '@/components'
import { serverApi, scanApi, errorLogApi } from '@/api'
// State
const servers = ref([])
const loading = ref(false)
const scanningServerId = ref(null)
const scanningAll = ref(false)
const progressMap = ref({})
const recentErrors = ref([])
// Error detail
const showErrorModal = ref(false)
const selectedError = ref(null)
// Computed
const activeServers = computed(() => servers.value.filter(s => s.active))
// Load data
const loadServers = async () => {
loading.value = true
try {
servers.value = await serverApi.getAll()
} catch (e) {
console.error('Failed to load servers:', e)
} finally {
loading.value = false
}
}
const loadRecentErrors = async () => {
try {
const result = await errorLogApi.search({ page: 0, size: 10 })
recentErrors.value = result.content || []
} catch (e) {
console.error('Failed to load recent errors:', e)
}
}
// Scan single server
const scanServer = (server) => {
scanningServerId.value = server.id
progressMap.value[server.id] = {
status: 'RUNNING',
currentPath: '',
currentFile: '',
totalFiles: 0,
scannedFiles: 0,
errorsFound: 0
}
scanApi.startWithProgress(
server.id,
(progress) => {
progressMap.value[server.id] = progress
},
(result) => {
scanningServerId.value = null
if (result.success) {
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'SUCCESS',
message: `완료: ${result.filesScanned}개 파일, ${result.errorsFound}개 에러`
}
} else {
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: result.error
}
}
loadServers()
loadRecentErrors()
// 3초 후 진행상황 제거
setTimeout(() => {
delete progressMap.value[server.id]
}, 5000)
},
(error) => {
scanningServerId.value = null
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: error
}
}
)
}
// Scan all servers
const scanAllServers = () => {
scanningAll.value = true
scanApi.startAllWithProgress(
(progress) => {
progressMap.value[progress.serverId] = progress
},
(results) => {
scanningAll.value = false
loadServers()
loadRecentErrors()
// 3초 후 진행상황 제거
setTimeout(() => {
progressMap.value = {}
}, 5000)
},
(error) => {
scanningAll.value = false
alert('분석 실패: ' + error)
}
)
}
// Error detail
const showErrorDetail = (error) => {
selectedError.value = error
showErrorModal.value = true
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getStatusText = (progress) => {
if (progress.status === 'SUCCESS') return progress.message || '완료'
if (progress.status === 'FAILED') return progress.message || '실패'
if (progress.currentFile) return `분석중: ${progress.currentFile}`
if (progress.currentPath) return `경로: ${progress.currentPath}`
return '준비중...'
}
const getProgressPercent = (progress) => {
if (progress.totalFiles === 0) return 0
return Math.round((progress.scannedFiles / progress.totalFiles) * 100)
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadServers()
loadRecentErrors()
})
</script>
<style scoped>
.dashboard h2 {
margin-bottom: 1rem;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.server-list {
background: white;
.dashboard-header h2 {
margin: 0;
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.server-card {
transition: box-shadow 0.2s;
}
.server-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.server-title {
display: flex;
align-items: center;
gap: 10px;
}
.server-title h4 {
margin: 0;
font-size: 16px;
}
.server-info {
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
color: #666;
font-size: 13px;
}
.info-row .value {
font-weight: 500;
}
.info-row .value.has-error {
color: #e74c3c;
}
.progress-section {
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 12px;
}
table {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.status-text {
font-size: 13px;
color: #333;
}
.progress-bar-container {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background: #3498db;
transition: width 0.3s;
}
.progress-details {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
}
.empty-card {
text-align: center;
}
.empty-content {
padding: 40px 20px;
}
.empty-content p {
margin-bottom: 16px;
color: #666;
}
.recent-errors {
margin-top: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h3 {
margin: 0;
}
.error-table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
.error-table th,
.error-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
}
.empty {
text-align: center;
color: #999;
.error-table tbody tr {
cursor: pointer;
transition: background 0.2s;
}
.error-table tbody tr:hover {
background: #f8f9fa;
}
.summary-cell {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Error Detail Modal */
.error-detail {
max-height: 60vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
}
.context-box {
padding: 12px;
background: #2d2d2d;
color: #f8f8f2;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
margin: 0;
}
</style>

View File

@@ -0,0 +1,554 @@
<template>
<div class="error-history">
<Card>
<template #header>
<div class="card-header-content">
<h3>에러 이력</h3>
<div class="header-actions">
<Button size="sm" variant="secondary" @click="exportHtml" :loading="exporting === 'html'">
HTML
</Button>
<Button size="sm" variant="secondary" @click="exportTxt" :loading="exporting === 'txt'">
TXT
</Button>
</div>
</div>
</template>
<!-- 필터 -->
<div class="filters">
<div class="filter-row">
<FormInput
v-model="filters.serverId"
label="서버"
type="select"
:options="serverOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.patternId"
label="패턴"
type="select"
:options="patternOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.severity"
label="심각도"
type="select"
:options="severityOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.keyword"
label="키워드"
placeholder="검색어 입력..."
/>
</div>
<div class="filter-row">
<FormInput
v-model="filters.startDate"
label="시작일"
type="date"
/>
<FormInput
v-model="filters.endDate"
label="종료일"
type="date"
/>
<div class="filter-actions">
<Button @click="search">검색</Button>
<Button variant="secondary" @click="resetFilters">초기화</Button>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="results-section">
<div class="results-header">
<span v-if="totalElements > 0"> {{ totalElements }}</span>
</div>
<div class="table-wrapper">
<table class="error-table" v-if="errors.length > 0">
<thead>
<tr>
<th class="col-time">발생시간</th>
<th class="col-server">서버</th>
<th class="col-severity">심각도</th>
<th class="col-pattern">패턴</th>
<th class="col-summary">요약</th>
<th class="col-action">작업</th>
</tr>
</thead>
<tbody>
<tr v-for="error in errors" :key="error.id">
<td class="col-time">{{ formatDate(error.occurredAt) }}</td>
<td class="col-server">{{ error.serverName }}</td>
<td class="col-severity">
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="col-pattern">{{ error.patternName }}</td>
<td class="col-summary">{{ truncate(error.summary, 50) }}</td>
<td class="col-action">
<Button size="sm" variant="secondary" @click="showDetail(error)">상세</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="errors.length === 0 && !loading" class="empty-result">
<p>검색 결과가 없습니다.</p>
</div>
<div v-if="loading" class="loading-result">
<p>로딩중...</p>
</div>
<!-- 페이지네이션 -->
<div v-if="totalPages > 1" class="pagination">
<Button
size="sm"
variant="secondary"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
이전
</Button>
<span class="page-info">{{ currentPage + 1 }} / {{ totalPages }}</span>
<Button
size="sm"
variant="secondary"
:disabled="currentPage >= totalPages - 1"
@click="goToPage(currentPage + 1)"
>
다음
</Button>
</div>
</div>
</Card>
<!-- 상세 모달 -->
<Modal v-model="showDetailModal" title="에러 상세" width="900px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
<div class="detail-item">
<label>파일</label>
<span class="file-path">{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showDetailModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Card, Button, Badge, Modal, FormInput } from '@/components'
import { serverApi, patternApi, errorLogApi } from '@/api'
// State
const loading = ref(false)
const exporting = ref(null)
const errors = ref([])
const totalElements = ref(0)
const totalPages = ref(0)
const currentPage = ref(0)
const pageSize = 20
// Filters
const filters = reactive({
serverId: '',
patternId: '',
severity: '',
keyword: '',
startDate: '',
endDate: ''
})
// Options
const serverOptions = ref([])
const patternOptions = ref([])
const severityOptions = [
{ value: '', label: '전체' },
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// Detail Modal
const showDetailModal = ref(false)
const selectedError = ref(null)
// Load options
const loadOptions = async () => {
try {
const servers = await serverApi.getAll()
serverOptions.value = [
{ value: '', label: '전체' },
...servers.map(s => ({ value: s.id, label: s.name }))
]
const patterns = await patternApi.getAll()
patternOptions.value = [
{ value: '', label: '전체' },
...patterns.map(p => ({ value: p.id, label: p.name }))
]
} catch (e) {
console.error('Failed to load options:', e)
}
}
// Search
const search = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
size: pageSize
}
if (filters.serverId) params.serverId = filters.serverId
if (filters.patternId) params.patternId = filters.patternId
if (filters.severity) params.severity = filters.severity
if (filters.keyword) params.keyword = filters.keyword
if (filters.startDate) params.startDate = filters.startDate + 'T00:00:00'
if (filters.endDate) params.endDate = filters.endDate + 'T23:59:59'
const result = await errorLogApi.search(params)
errors.value = result.content || []
totalElements.value = result.totalElements || 0
totalPages.value = result.totalPages || 0
} catch (e) {
console.error('Failed to search errors:', e)
errors.value = []
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.serverId = ''
filters.patternId = ''
filters.severity = ''
filters.keyword = ''
filters.startDate = ''
filters.endDate = ''
currentPage.value = 0
search()
}
const goToPage = (page) => {
currentPage.value = page
search()
}
// Detail
const showDetail = async (error) => {
try {
selectedError.value = await errorLogApi.getById(error.id)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load error detail:', e)
selectedError.value = error
showDetailModal.value = true
}
}
// Export
const buildExportParams = () => {
const params = new URLSearchParams()
if (filters.serverId) params.append('serverId', filters.serverId)
if (filters.patternId) params.append('patternId', filters.patternId)
if (filters.severity) params.append('severity', filters.severity)
if (filters.keyword) params.append('keyword', filters.keyword)
if (filters.startDate) params.append('startDate', filters.startDate + 'T00:00:00')
if (filters.endDate) params.append('endDate', filters.endDate + 'T23:59:59')
return params.toString()
}
const exportHtml = () => {
exporting.value = 'html'
const params = buildExportParams()
window.open(`/api/export/html?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
const exportTxt = () => {
exporting.value = 'txt'
const params = buildExportParams()
window.open(`/api/export/txt?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadOptions()
search()
})
</script>
<style scoped>
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-actions :deep(.btn) {
white-space: nowrap;
min-width: 60px;
}
.filters {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.filter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 12px;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
gap: 8px;
padding-bottom: 4px;
}
.filter-actions :deep(.btn) {
white-space: nowrap;
}
.results-section {
margin-top: 16px;
}
.results-header {
margin-bottom: 12px;
color: #666;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.error-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.error-table th,
.error-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid #eee;
overflow: hidden;
text-overflow: ellipsis;
}
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.error-table tbody tr:hover {
background: #fafafa;
}
/* Column widths */
.col-time {
width: 140px;
white-space: nowrap;
}
.col-server {
width: 130px;
white-space: nowrap;
}
.col-severity {
width: 90px;
white-space: nowrap;
}
.col-pattern {
width: 120px;
white-space: nowrap;
}
.col-summary {
min-width: 200px;
}
.col-action {
width: 70px;
text-align: center;
}
.col-action :deep(.btn) {
white-space: nowrap;
padding: 4px 12px;
}
.empty-result,
.loading-result {
text-align: center;
padding: 40px;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.pagination :deep(.btn) {
white-space: nowrap;
min-width: 50px;
}
.page-info {
font-size: 14px;
color: #666;
}
/* Error Detail Modal */
.error-detail {
max-height: 65vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.file-path {
word-break: break-all;
font-family: monospace;
font-size: 13px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
word-break: break-all;
}
.context-box {
padding: 12px;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -1,92 +1,554 @@
<template>
<div class="error-logs">
<h2>에러 이력</h2>
<p>수집된 에러 목록을 조회합니다.</p>
<div class="filters">
<select><option>전체 서버</option></select>
<input type="date" placeholder="시작일">
<input type="date" placeholder="종료일">
<input type="text" placeholder="키워드 검색">
<button>검색</button>
</div>
<div class="error-list">
<table>
<thead>
<tr>
<th>발생일시</th>
<th>서버</th>
<th>패턴</th>
<th>에러 요약</th>
<th>상세</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="empty">에러 이력이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<div class="error-history">
<Card>
<template #header>
<div class="card-header-content">
<h3>에러 이력</h3>
<div class="header-actions">
<Button size="sm" variant="secondary" @click="exportHtml" :loading="exporting === 'html'">
HTML
</Button>
<Button size="sm" variant="secondary" @click="exportTxt" :loading="exporting === 'txt'">
TXT
</Button>
</div>
</div>
</template>
<!-- 필터 -->
<div class="filters">
<div class="filter-row">
<FormInput
v-model="filters.serverId"
label="서버"
type="select"
:options="serverOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.patternId"
label="패턴"
type="select"
:options="patternOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.severity"
label="심각도"
type="select"
:options="severityOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.keyword"
label="키워드"
placeholder="검색어 입력..."
/>
</div>
<div class="filter-row">
<FormInput
v-model="filters.startDate"
label="시작일"
type="date"
/>
<FormInput
v-model="filters.endDate"
label="종료일"
type="date"
/>
<div class="filter-actions">
<Button @click="search">검색</Button>
<Button variant="secondary" @click="resetFilters">초기화</Button>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="results-section">
<div class="results-header">
<span v-if="totalElements > 0"> {{ totalElements }}</span>
</div>
<div class="table-wrapper">
<table class="error-table" v-if="errors.length > 0">
<thead>
<tr>
<th class="col-time">발생시간</th>
<th class="col-server">서버</th>
<th class="col-severity">심각도</th>
<th class="col-pattern">패턴</th>
<th class="col-summary">요약</th>
<th class="col-action">작업</th>
</tr>
</thead>
<tbody>
<tr v-for="error in errors" :key="error.id">
<td class="col-time">{{ formatDate(error.occurredAt) }}</td>
<td class="col-server">{{ error.serverName }}</td>
<td class="col-severity">
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="col-pattern">{{ error.patternName }}</td>
<td class="col-summary">{{ truncate(error.summary, 50) }}</td>
<td class="col-action">
<Button size="sm" variant="secondary" @click="showDetail(error)">상세</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="errors.length === 0 && !loading" class="empty-result">
<p>검색 결과가 없습니다.</p>
</div>
<div v-if="loading" class="loading-result">
<p>로딩중...</p>
</div>
<!-- 페이지네이션 -->
<div v-if="totalPages > 1" class="pagination">
<Button
size="sm"
variant="secondary"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
이전
</Button>
<span class="page-info">{{ currentPage + 1 }} / {{ totalPages }}</span>
<Button
size="sm"
variant="secondary"
:disabled="currentPage >= totalPages - 1"
@click="goToPage(currentPage + 1)"
>
다음
</Button>
</div>
</div>
</Card>
<!-- 상세 모달 -->
<Modal v-model="showDetailModal" title="에러 상세" width="900px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
<div class="detail-item">
<label>파일</label>
<span class="file-path">{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showDetailModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Card, Button, Badge, Modal, FormInput } from '@/components'
import { serverApi, patternApi, errorLogApi } from '@/api'
// State
const loading = ref(false)
const exporting = ref(null)
const errors = ref([])
const totalElements = ref(0)
const totalPages = ref(0)
const currentPage = ref(0)
const pageSize = 20
// Filters
const filters = reactive({
serverId: '',
patternId: '',
severity: '',
keyword: '',
startDate: '',
endDate: ''
})
// Options
const serverOptions = ref([])
const patternOptions = ref([])
const severityOptions = [
{ value: '', label: '전체' },
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// Detail Modal
const showDetailModal = ref(false)
const selectedError = ref(null)
// Load options
const loadOptions = async () => {
try {
const servers = await serverApi.getAll()
serverOptions.value = [
{ value: '', label: '전체' },
...servers.map(s => ({ value: s.id, label: s.name }))
]
const patterns = await patternApi.getAll()
patternOptions.value = [
{ value: '', label: '전체' },
...patterns.map(p => ({ value: p.id, label: p.name }))
]
} catch (e) {
console.error('Failed to load options:', e)
}
}
// Search
const search = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
size: pageSize
}
if (filters.serverId) params.serverId = filters.serverId
if (filters.patternId) params.patternId = filters.patternId
if (filters.severity) params.severity = filters.severity
if (filters.keyword) params.keyword = filters.keyword
if (filters.startDate) params.startDate = filters.startDate + 'T00:00:00'
if (filters.endDate) params.endDate = filters.endDate + 'T23:59:59'
const result = await errorLogApi.search(params)
errors.value = result.content || []
totalElements.value = result.totalElements || 0
totalPages.value = result.totalPages || 0
} catch (e) {
console.error('Failed to search errors:', e)
errors.value = []
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.serverId = ''
filters.patternId = ''
filters.severity = ''
filters.keyword = ''
filters.startDate = ''
filters.endDate = ''
currentPage.value = 0
search()
}
const goToPage = (page) => {
currentPage.value = page
search()
}
// Detail
const showDetail = async (error) => {
try {
selectedError.value = await errorLogApi.getById(error.id)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load error detail:', e)
selectedError.value = error
showDetailModal.value = true
}
}
// Export
const buildExportParams = () => {
const params = new URLSearchParams()
if (filters.serverId) params.append('serverId', filters.serverId)
if (filters.patternId) params.append('patternId', filters.patternId)
if (filters.severity) params.append('severity', filters.severity)
if (filters.keyword) params.append('keyword', filters.keyword)
if (filters.startDate) params.append('startDate', filters.startDate + 'T00:00:00')
if (filters.endDate) params.append('endDate', filters.endDate + 'T23:59:59')
return params.toString()
}
const exportHtml = () => {
exporting.value = 'html'
const params = buildExportParams()
window.open(`/api/export/html?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
const exportTxt = () => {
exporting.value = 'txt'
const params = buildExportParams()
window.open(`/api/export/txt?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadOptions()
search()
})
</script>
<style scoped>
.error-logs h2 {
margin-bottom: 1rem;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-actions :deep(.btn) {
white-space: nowrap;
min-width: 60px;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters select,
.filters input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.filters button {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error-list {
background: white;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
table {
.filter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 12px;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
gap: 8px;
padding-bottom: 4px;
}
.filter-actions :deep(.btn) {
white-space: nowrap;
}
.results-section {
margin-top: 16px;
}
.results-header {
margin-bottom: 12px;
color: #666;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.error-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 0.75rem;
.error-table th,
.error-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid #eee;
overflow: hidden;
text-overflow: ellipsis;
}
th {
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.empty {
.error-table tbody tr:hover {
background: #fafafa;
}
/* Column widths */
.col-time {
width: 140px;
white-space: nowrap;
}
.col-server {
width: 130px;
white-space: nowrap;
}
.col-severity {
width: 90px;
white-space: nowrap;
}
.col-pattern {
width: 120px;
white-space: nowrap;
}
.col-summary {
min-width: 200px;
}
.col-action {
width: 70px;
text-align: center;
color: #999;
}
.col-action :deep(.btn) {
white-space: nowrap;
padding: 4px 12px;
}
.empty-result,
.loading-result {
text-align: center;
padding: 40px;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.pagination :deep(.btn) {
white-space: nowrap;
min-width: 50px;
}
.page-info {
font-size: 14px;
color: #666;
}
/* Error Detail Modal */
.error-detail {
max-height: 65vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.file-path {
word-break: break-all;
font-family: monospace;
font-size: 13px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
word-break: break-all;
}
.context-box {
padding: 12px;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -1,56 +1,424 @@
<template>
<div class="pattern-manage">
<h2>패턴 관리</h2>
<p>에러 검출 패턴을 관리합니다.</p>
<div class="actions">
<button class="btn-primary">+ 패턴 추가</button>
</div>
<div class="pattern-list">
<table>
<thead>
<tr>
<th>패턴명</th>
<th>정규식</th>
<th>심각도</th>
<th>컨텍스트</th>
<th>활성화</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 패턴이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<Card>
<template #header>
<div class="card-header-content">
<h3>패턴 관리</h3>
<Button @click="openAddModal">+ 패턴 추가</Button>
</div>
</template>
<DataTable
:columns="columns"
:data="patterns"
:loading="loading"
empty-text="등록된 패턴이 없습니다."
>
<template #severity="{ value }">
<Badge :variant="getSeverityVariant(value)">{{ value }}</Badge>
</template>
<template #regex="{ value }">
<code class="regex-code">{{ truncate(value, 50) }}</code>
</template>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="secondary" @click="openTestModal(row)">테스트</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
</div>
</template>
</DataTable>
</Card>
<!-- 패턴 추가/수정 모달 -->
<Modal v-model="showPatternModal" :title="isEdit ? '패턴 수정' : '패턴 추가'" width="600px">
<form @submit.prevent="savePattern">
<FormInput
v-model="form.name"
label="패턴명"
placeholder="예: NullPointerException"
required
/>
<FormInput
v-model="form.regex"
label="정규식"
type="textarea"
:rows="3"
placeholder="예: (Exception|Error|SEVERE|FATAL)"
required
hint="Java 정규식 문법을 사용합니다."
/>
<FormInput
v-model="form.severity"
label="심각도"
type="select"
:options="severityOptions"
required
/>
<FormInput
v-model="form.contextLines"
label="컨텍스트 라인 수"
type="number"
placeholder="5"
hint="에러 전후로 캡처할 라인 수"
/>
<FormInput
v-model="form.description"
label="설명"
type="textarea"
:rows="2"
placeholder="이 패턴에 대한 설명"
/>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.active" />
활성화
</label>
</div>
</form>
<template #footer>
<Button variant="secondary" @click="showPatternModal = false">취소</Button>
<Button @click="savePattern" :loading="saving">저장</Button>
</template>
</Modal>
<!-- 패턴 테스트 모달 -->
<Modal v-model="showTestModal" :title="`패턴 테스트 - ${testTarget?.name || ''}`" width="700px">
<div class="test-section">
<div class="test-pattern">
<label>정규식</label>
<code class="regex-display">{{ testTarget?.regex }}</code>
</div>
<FormInput
v-model="testSampleText"
label="테스트할 텍스트"
type="textarea"
:rows="6"
placeholder="로그 텍스트를 붙여넣으세요..."
/>
<Button @click="runPatternTest" :loading="testing" :disabled="!testSampleText">
테스트 실행
</Button>
<div v-if="testResult" class="test-result" :class="{ success: testResult.matched, fail: !testResult.matched }">
<h4>테스트 결과</h4>
<div v-if="!testResult.validRegex" class="error-msg">
정규식 오류: {{ testResult.errorMessage }}
</div>
<div v-else-if="testResult.matched">
<p> 매칭 성공!</p>
<div class="match-info">
<label>매칭된 텍스트:</label>
<code>{{ testResult.matchedText }}</code>
</div>
<div class="match-info">
<label>위치:</label>
<span>{{ testResult.matchStart }} ~ {{ testResult.matchEnd }}</span>
</div>
</div>
<div v-else>
<p> 매칭 없음</p>
</div>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showTestModal = false">닫기</Button>
</template>
</Modal>
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="패턴 삭제" width="400px">
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 패턴을 삭제하시겠습니까?</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button variant="danger" @click="deletePattern" :loading="deleting">삭제</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { patternApi } from '@/api'
const columns = [
{ key: 'name', label: '패턴명', width: '150px' },
{ key: 'regex', label: '정규식' },
{ key: 'severity', label: '심각도', width: '100px' },
{ key: 'contextLines', label: '컨텍스트', width: '90px' },
{ key: 'active', label: '상태', width: '80px' }
]
const severityOptions = [
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// State
const patterns = ref([])
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const testing = ref(false)
// Pattern Modal
const showPatternModal = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({
name: '',
regex: '',
severity: 'ERROR',
contextLines: 5,
description: '',
active: true
})
// Test Modal
const showTestModal = ref(false)
const testTarget = ref(null)
const testSampleText = ref('')
const testResult = ref(null)
// Delete Modal
const showDeleteModal = ref(false)
const deleteTarget = ref(null)
// Load patterns
const loadPatterns = async () => {
loading.value = true
try {
patterns.value = await patternApi.getAll()
} catch (e) {
console.error('Failed to load patterns:', e)
alert('패턴 목록을 불러오는데 실패했습니다.')
} finally {
loading.value = false
}
}
// Open Add Modal
const openAddModal = () => {
isEdit.value = false
editId.value = null
form.value = {
name: '',
regex: '',
severity: 'ERROR',
contextLines: 5,
description: '',
active: true
}
showPatternModal.value = true
}
// Open Edit Modal
const openEditModal = (pattern) => {
isEdit.value = true
editId.value = pattern.id
form.value = {
name: pattern.name,
regex: pattern.regex,
severity: pattern.severity,
contextLines: pattern.contextLines,
description: pattern.description || '',
active: pattern.active
}
showPatternModal.value = true
}
// Save Pattern
const savePattern = async () => {
if (!form.value.name || !form.value.regex) {
alert('필수 항목을 입력해주세요.')
return
}
saving.value = true
try {
if (isEdit.value) {
await patternApi.update(editId.value, form.value)
} else {
await patternApi.create(form.value)
}
showPatternModal.value = false
await loadPatterns()
} catch (e) {
console.error('Failed to save pattern:', e)
alert('저장에 실패했습니다. 정규식 문법을 확인해주세요.')
} finally {
saving.value = false
}
}
// Delete
const confirmDelete = (pattern) => {
deleteTarget.value = pattern
showDeleteModal.value = true
}
const deletePattern = async () => {
deleting.value = true
try {
await patternApi.delete(deleteTarget.value.id)
showDeleteModal.value = false
await loadPatterns()
} catch (e) {
console.error('Failed to delete pattern:', e)
alert('삭제에 실패했습니다.')
} finally {
deleting.value = false
}
}
// Test Modal
const openTestModal = (pattern) => {
testTarget.value = pattern
testSampleText.value = ''
testResult.value = null
showTestModal.value = true
}
const runPatternTest = async () => {
if (!testSampleText.value) return
testing.value = true
try {
testResult.value = await patternApi.test(testTarget.value.regex, testSampleText.value)
} catch (e) {
console.error('Failed to test pattern:', e)
alert('테스트 실행에 실패했습니다.')
} finally {
testing.value = false
}
}
// Utils
const getSeverityVariant = (severity) => {
const map = {
'CRITICAL': 'critical',
'ERROR': 'error',
'WARN': 'warn'
}
return map[severity] || 'default'
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
onMounted(() => {
loadPatterns()
})
</script>
<style scoped>
.pattern-manage h2 { margin-bottom: 1rem; }
.actions { margin-bottom: 1rem; }
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.action-buttons {
display: flex;
gap: 4px;
}
.regex-code {
font-family: monospace;
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.pattern-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.test-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.test-pattern label {
display: block;
font-weight: 500;
margin-bottom: 6px;
}
.regex-display {
display: block;
font-family: monospace;
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
font-size: 13px;
word-break: break-all;
}
.test-result {
padding: 16px;
border-radius: 8px;
margin-top: 8px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.test-result.fail {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.test-result h4 {
margin: 0 0 12px 0;
}
.test-result p {
margin: 0;
}
.match-info {
margin-top: 8px;
}
.match-info label {
font-weight: 500;
margin-right: 8px;
}
.match-info code {
background: rgba(0,0,0,0.1);
padding: 2px 6px;
border-radius: 3px;
}
.error-msg {
color: #721c24;
}
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
.empty { text-align: center; color: #999; }
</style>

View File

@@ -1,79 +1,511 @@
<template>
<div class="server-manage">
<h2>서버 관리</h2>
<p>SFTP 서버 접속 정보를 관리합니다.</p>
<div class="actions">
<button class="btn-primary">+ 서버 추가</button>
</div>
<div class="server-list">
<table>
<thead>
<tr>
<th>서버명</th>
<th>호스트</th>
<th>포트</th>
<th>인증방식</th>
<th>활성화</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 서버가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<Card>
<template #header>
<div class="card-header-content">
<h3>서버 관리</h3>
<Button @click="openAddModal">+ 서버 추가</Button>
</div>
</template>
<DataTable
:columns="columns"
:data="servers"
:loading="loading"
empty-text="등록된 서버가 없습니다."
>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #authType="{ value }">
{{ value === 'PASSWORD' ? '비밀번호' : '키 파일' }}
</template>
<template #lastScanAt="{ value }">
{{ value ? formatDate(value) : '-' }}
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="success" @click="testConnection(row)" :loading="testingId === row.id">테스트</Button>
<Button size="sm" variant="secondary" @click="openLogPathModal(row)">경로</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
</div>
</template>
</DataTable>
</Card>
<!-- 서버 추가/수정 모달 -->
<Modal v-model="showServerModal" :title="isEdit ? '서버 수정' : '서버 추가'" width="500px">
<form @submit.prevent="saveServer">
<FormInput
v-model="form.name"
label="서버명"
placeholder="예: 운영서버1"
required
/>
<FormInput
v-model="form.host"
label="호스트"
placeholder="예: 192.168.1.100"
required
/>
<FormInput
v-model="form.port"
label="포트"
type="number"
placeholder="22"
/>
<FormInput
v-model="form.username"
label="사용자명"
placeholder="예: root"
required
/>
<FormInput
v-model="form.authType"
label="인증 방식"
type="select"
:options="authTypeOptions"
required
/>
<FormInput
v-if="form.authType === 'PASSWORD'"
v-model="form.password"
label="비밀번호"
type="password"
:placeholder="isEdit ? '변경 시에만 입력' : '비밀번호 입력'"
:required="!isEdit"
/>
<FormInput
v-if="form.authType === 'KEY_FILE'"
v-model="form.keyFilePath"
label="키 파일 경로"
placeholder="예: C:\Users\user\.ssh\id_rsa"
required
/>
<FormInput
v-if="form.authType === 'KEY_FILE'"
v-model="form.passphrase"
label="Passphrase"
type="password"
:placeholder="isEdit ? '변경 시에만 입력' : 'Passphrase (없으면 비워두세요)'"
/>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.active" />
활성화
</label>
</div>
</form>
<template #footer>
<Button variant="secondary" @click="showServerModal = false">취소</Button>
<Button @click="saveServer" :loading="saving">저장</Button>
</template>
</Modal>
<!-- 로그 경로 관리 모달 -->
<Modal v-model="showLogPathModal" :title="`로그 경로 관리 - ${selectedServer?.name || ''}`" width="700px">
<div class="log-path-section">
<div class="log-path-form">
<h4>경로 추가</h4>
<div class="log-path-inputs">
<FormInput
v-model="logPathForm.path"
label="경로"
placeholder="예: /var/log/tomcat/"
/>
<FormInput
v-model="logPathForm.filePattern"
label="파일 패턴"
placeholder="예: *.log, catalina.*.log"
/>
<FormInput
v-model="logPathForm.description"
label="설명"
placeholder="예: Tomcat 로그"
/>
</div>
<Button size="sm" @click="addLogPath" :disabled="!logPathForm.path || !logPathForm.filePattern">
추가
</Button>
</div>
<div class="log-path-list">
<h4>등록된 경로</h4>
<table v-if="logPaths.length > 0">
<thead>
<tr>
<th>경로</th>
<th>파일 패턴</th>
<th>설명</th>
<th>활성</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="lp in logPaths" :key="lp.id">
<td>{{ lp.path }}</td>
<td>{{ lp.filePattern }}</td>
<td>{{ lp.description || '-' }}</td>
<td>
<Badge :variant="lp.active ? 'success' : 'default'">
{{ lp.active ? 'Y' : 'N' }}
</Badge>
</td>
<td>
<Button size="sm" variant="danger" @click="deleteLogPath(lp.id)">삭제</Button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-text">등록된 경로가 없습니다.</p>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showLogPathModal = false">닫기</Button>
</template>
</Modal>
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="서버 삭제" width="400px">
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 서버를 삭제하시겠습니까?</p>
<p class="warning-text">관련된 모든 로그 경로와 에러 이력도 함께 삭제됩니다.</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button variant="danger" @click="deleteServer" :loading="deleting">삭제</Button>
</template>
</Modal>
<!-- 연결 테스트 결과 모달 -->
<Modal v-model="showTestResultModal" title="연결 테스트 결과" width="450px">
<div class="test-result" :class="{ success: testResult?.success, fail: !testResult?.success }">
<div v-if="testResult?.success">
<p> {{ testResult.message }}</p>
</div>
<div v-else>
<p> {{ testResult?.error }}</p>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showTestResultModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { serverApi, logPathApi } from '@/api'
const columns = [
{ key: 'name', label: '서버명', width: '150px' },
{ key: 'host', label: '호스트' },
{ key: 'port', label: '포트', width: '80px' },
{ key: 'authType', label: '인증방식', width: '100px' },
{ key: 'active', label: '상태', width: '80px' },
{ key: 'lastScanAt', label: '마지막 분석', width: '150px' }
]
const authTypeOptions = [
{ value: 'PASSWORD', label: '비밀번호' },
{ value: 'KEY_FILE', label: '키 파일' }
]
// State
const servers = ref([])
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// Server Modal
const showServerModal = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({
name: '',
host: '',
port: 22,
username: '',
authType: 'PASSWORD',
password: '',
keyFilePath: '',
passphrase: '',
active: true
})
// LogPath Modal
const showLogPathModal = ref(false)
const selectedServer = ref(null)
const logPaths = ref([])
const logPathForm = ref({
path: '',
filePattern: '',
description: ''
})
// Delete Modal
const showDeleteModal = ref(false)
const deleteTarget = ref(null)
// Test Connection
const testingId = ref(null)
const showTestResultModal = ref(false)
const testResult = ref(null)
// Load servers
const loadServers = async () => {
loading.value = true
try {
servers.value = await serverApi.getAll()
} catch (e) {
console.error('Failed to load servers:', e)
alert('서버 목록을 불러오는데 실패했습니다.')
} finally {
loading.value = false
}
}
// Open Add Modal
const openAddModal = () => {
isEdit.value = false
editId.value = null
form.value = {
name: '',
host: '',
port: 22,
username: '',
authType: 'PASSWORD',
password: '',
keyFilePath: '',
passphrase: '',
active: true
}
showServerModal.value = true
}
// Open Edit Modal
const openEditModal = (server) => {
isEdit.value = true
editId.value = server.id
form.value = {
name: server.name,
host: server.host,
port: server.port,
username: server.username,
authType: server.authType,
password: '',
keyFilePath: server.keyFilePath || '',
passphrase: '',
active: server.active
}
showServerModal.value = true
}
// Save Server
const saveServer = async () => {
if (!form.value.name || !form.value.host || !form.value.username) {
alert('필수 항목을 입력해주세요.')
return
}
saving.value = true
try {
if (isEdit.value) {
await serverApi.update(editId.value, form.value)
} else {
await serverApi.create(form.value)
}
showServerModal.value = false
await loadServers()
} catch (e) {
console.error('Failed to save server:', e)
alert('저장에 실패했습니다.')
} finally {
saving.value = false
}
}
// Delete
const confirmDelete = (server) => {
deleteTarget.value = server
showDeleteModal.value = true
}
const deleteServer = async () => {
deleting.value = true
try {
await serverApi.delete(deleteTarget.value.id)
showDeleteModal.value = false
await loadServers()
} catch (e) {
console.error('Failed to delete server:', e)
alert('삭제에 실패했습니다.')
} finally {
deleting.value = false
}
}
// Test Connection
const testConnection = async (server) => {
testingId.value = server.id
try {
testResult.value = await serverApi.testConnection(server.id)
showTestResultModal.value = true
} catch (e) {
console.error('Failed to test connection:', e)
testResult.value = { success: false, error: '연결 테스트 실패: ' + e.message }
showTestResultModal.value = true
} finally {
testingId.value = null
}
}
// Log Path Modal
const openLogPathModal = async (server) => {
selectedServer.value = server
logPathForm.value = { path: '', filePattern: '', description: '' }
try {
logPaths.value = await logPathApi.getByServerId(server.id)
} catch (e) {
console.error('Failed to load log paths:', e)
logPaths.value = []
}
showLogPathModal.value = true
}
const addLogPath = async () => {
try {
await logPathApi.create({
serverId: selectedServer.value.id,
path: logPathForm.value.path,
filePattern: logPathForm.value.filePattern,
description: logPathForm.value.description,
active: true
})
logPaths.value = await logPathApi.getByServerId(selectedServer.value.id)
logPathForm.value = { path: '', filePattern: '', description: '' }
} catch (e) {
console.error('Failed to add log path:', e)
alert('경로 추가에 실패했습니다.')
}
}
const deleteLogPath = async (id) => {
if (!confirm('이 경로를 삭제하시겠습니까?')) return
try {
await logPathApi.delete(id)
logPaths.value = await logPathApi.getByServerId(selectedServer.value.id)
} catch (e) {
console.error('Failed to delete log path:', e)
alert('경로 삭제에 실패했습니다.')
}
}
// Utils
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleString('ko-KR')
}
onMounted(() => {
loadServers()
})
</script>
<style scoped>
.server-manage h2 {
margin-bottom: 1rem;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions {
margin-bottom: 1rem;
.card-header-content h3 {
margin: 0;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
.action-buttons {
display: flex;
gap: 4px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.server-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.log-path-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #333;
}
table {
.log-path-form {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.log-path-inputs {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.log-path-list table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
.log-path-list th,
.log-path-list td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
.log-path-list th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
}
.empty {
.empty-text {
color: #6c757d;
text-align: center;
color: #999;
padding: 20px;
}
.warning-text {
color: #e74c3c;
font-size: 14px;
}
.test-result {
padding: 16px;
border-radius: 8px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.test-result.fail {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.test-result p {
margin: 0;
}
</style>

View File

@@ -1,79 +1,223 @@
<template>
<div class="settings">
<h2>설정</h2>
<p>애플리케이션 설정을 관리합니다.</p>
<div class="settings-form">
<div class="form-group">
<label>내보내기 경로</label>
<input type="text" v-model="form.exportPath" placeholder="./exports">
<Card>
<template #header>
<div class="card-header-content">
<h3>설정</h3>
</div>
</template>
<div v-if="loading" class="loading">로딩중...</div>
<form v-else @submit.prevent="saveSettings" class="settings-form">
<div class="setting-section">
<h4>일반 설정</h4>
<FormInput
v-model="settings['server.port']"
label="서버 포트"
type="number"
hint="애플리케이션이 실행될 포트 번호 (기본: 8080)"
/>
</div>
<div class="setting-section">
<h4>내보내기 설정</h4>
<FormInput
v-model="settings['export.path']"
label="내보내기 경로"
placeholder="예: C:\LogHunter\exports"
hint="리포트 파일이 저장될 기본 경로"
/>
</div>
<div class="setting-section">
<h4>데이터 관리</h4>
<FormInput
v-model="settings['retention.days']"
label="로그 보관 기간 (일)"
type="number"
hint="에러 로그 데이터 보관 기간 (0 = 무제한)"
/>
</div>
<div class="setting-section">
<h4>스캔 설정</h4>
<FormInput
v-model="settings['scan.timeout']"
label="스캔 타임아웃 (초)"
type="number"
hint="SFTP 연결 및 파일 다운로드 타임아웃"
/>
<FormInput
v-model="settings['scan.maxFileSize']"
label="최대 파일 크기 (MB)"
type="number"
hint="분석할 로그 파일의 최대 크기"
/>
</div>
<div class="form-actions">
<Button @click="loadSettings" variant="secondary">초기화</Button>
<Button type="submit" :loading="saving">저장</Button>
</div>
</form>
</Card>
<!-- 정보 -->
<Card class="app-info">
<template #header>
<h3>애플리케이션 정보</h3>
</template>
<div class="info-list">
<div class="info-item">
<span class="label">버전</span>
<span class="value">1.0.0</span>
</div>
<div class="info-item">
<span class="label">프레임워크</span>
<span class="value">Spring Boot 3.2 + Vue 3</span>
</div>
<div class="info-item">
<span class="label">데이터베이스</span>
<span class="value">SQLite (./data/loghunter.db)</span>
</div>
</div>
<div class="form-group">
<label>로그 보관 기간 ()</label>
<input type="number" v-model="form.retentionDays" min="1">
</div>
<div class="form-group">
<label> 서버 포트</label>
<input type="number" v-model="form.port" min="1" max="65535">
</div>
<div class="actions">
<button class="btn-primary">저장</button>
</div>
</div>
</Card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { Card, Button, FormInput } from '@/components'
import { settingApi } from '@/api'
const form = ref({
exportPath: './exports',
retentionDays: 30,
port: 8080
const loading = ref(false)
const saving = ref(false)
const settings = ref({})
// 기본값
const defaultSettings = {
'server.port': '8080',
'export.path': './exports',
'retention.days': '90',
'scan.timeout': '30',
'scan.maxFileSize': '100'
}
const loadSettings = async () => {
loading.value = true
try {
const data = await settingApi.getAllAsMap()
settings.value = { ...defaultSettings, ...data }
} catch (e) {
console.error('Failed to load settings:', e)
settings.value = { ...defaultSettings }
} finally {
loading.value = false
}
}
const saveSettings = async () => {
saving.value = true
try {
// 각 설정 저장
for (const [key, value] of Object.entries(settings.value)) {
await settingApi.save({ key, value: String(value) })
}
alert('설정이 저장되었습니다.')
} catch (e) {
console.error('Failed to save settings:', e)
alert('설정 저장에 실패했습니다.')
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings h2 { margin-bottom: 1rem; }
.settings {
max-width: 800px;
}
.card-header-content h3 {
margin: 0;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.settings-form {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-width: 500px;
padding: 10px 0;
}
.form-group {
margin-bottom: 1rem;
.setting-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
.setting-section:last-of-type {
border-bottom: none;
}
.setting-section h4 {
margin: 0 0 20px 0;
font-size: 16px;
color: #2c3e50;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.app-info {
margin-top: 20px;
}
.app-info h3 {
margin: 0;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-item:last-child {
border-bottom: none;
}
.info-item .label {
color: #666;
}
.info-item .value {
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.actions {
margin-top: 1.5rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

View File

@@ -19,7 +19,7 @@ export default defineConfig({
}
},
build: {
outDir: 'dist',
outDir: '../src/main/resources/static',
emptyOutDir: true
}
})