update
This commit is contained in:
429
frontend/package-lock.json
generated
429
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
56
frontend/src/components/Badge.vue
Normal file
56
frontend/src/components/Badge.vue
Normal 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>
|
||||
118
frontend/src/components/Button.vue
Normal file
118
frontend/src/components/Button.vue
Normal 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>
|
||||
51
frontend/src/components/Card.vue
Normal file
51
frontend/src/components/Card.vue
Normal 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>
|
||||
120
frontend/src/components/DataTable.vue
Normal file
120
frontend/src/components/DataTable.vue
Normal 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>
|
||||
138
frontend/src/components/FormInput.vue
Normal file
138
frontend/src/components/FormInput.vue
Normal 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>
|
||||
108
frontend/src/components/Modal.vue
Normal file
108
frontend/src/components/Modal.vue
Normal 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">×</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>
|
||||
6
frontend/src/components/index.js
Normal file
6
frontend/src/components/index.js
Normal 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'
|
||||
@@ -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>
|
||||
|
||||
554
frontend/src/views/ErrorHistory.vue
Normal file
554
frontend/src/views/ErrorHistory.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: '../src/main/resources/static',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user