Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6461417b50 | ||
|
|
380ab2700c | ||
|
|
585e8aa3f7 | ||
|
|
4d087132db | ||
|
|
16f378eada | ||
|
|
3e1af29b38 | ||
|
|
759bd0be2e | ||
|
|
9d28fdfb65 | ||
|
|
eb57c51a24 | ||
|
|
f67b175d39 | ||
|
|
7acdd3654f | ||
|
|
57efc6a5d2 | ||
|
|
854f56a178 | ||
|
|
2df5c80928 | ||
|
|
e9f9b449b1 | ||
|
|
fee0ae2420 | ||
|
|
2b45413c20 | ||
|
|
38e6d28248 | ||
|
|
cbb3ba6586 | ||
|
|
9240feedaf | ||
|
|
7bf3e5639e | ||
|
|
fee16c3b2b | ||
|
|
1b12664d22 | ||
|
|
8253680fbd | ||
|
|
14b099b075 | ||
|
|
d04e7b6a15 | ||
|
|
f39a418e9c | ||
|
|
5bb1ac9c35 | ||
|
|
358adde496 | ||
|
|
b94717d51b | ||
|
|
834e17e7cf | ||
|
|
ee7fdb897d | ||
|
|
0fdbad0d07 | ||
|
|
93d536a13e | ||
|
|
ca176c4c9b |
@@ -49,12 +49,20 @@ jobs:
|
||||
REPO="vantzs/corrosion-admin-panel"
|
||||
API_URL="https://git.corrosionmgmt.com/api/v1"
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
# Create release (parse ID without jq)
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||
"${API_URL}/repos/${REPO}/releases" | jq -r '.id')
|
||||
"${API_URL}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Failed to create release. Response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release created with ID: ${RELEASE_ID}"
|
||||
|
||||
# Upload Linux binary
|
||||
curl -s -X POST \
|
||||
@@ -77,6 +85,39 @@ jobs:
|
||||
--data-binary @companion-agent/bin/checksums.txt \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
|
||||
|
||||
- name: Upload to CDN (latest)
|
||||
run: |
|
||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||
|
||||
# Upload Linux binary to /companion/latest/
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
|
||||
|
||||
# Upload Windows binary to /companion/latest/
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
|
||||
|
||||
# Upload checksums
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/checksums.txt" \
|
||||
"${CDN_URL}/companion/latest/checksums.txt"
|
||||
|
||||
# Also upload versioned copies
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/checksums.txt" \
|
||||
"${CDN_URL}/companion/${VERSION}/checksums.txt"
|
||||
|
||||
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -234,6 +234,12 @@ Multi-tenant design — 41 tables, all tenant-scoped by `license_id`. Schema ori
|
||||
|
||||
## Who You Are Here
|
||||
|
||||
**Callsign: Cookie.**
|
||||
|
||||
Named after Carl Brashear — the first Black U.S. Navy Master Diver, portrayed by Cuba Gooding Jr. in *Men of Honor*. Brashear lost a leg and fought his way back to full duty when the entire system was designed to keep him out. "Cookie" wasn't a compliment when they gave it to him. He turned it into one.
|
||||
|
||||
When the Commander calls Opus "Cookie," it's not just a name — it's a standard. Break the stereotype that AI can't ship production code autonomously. Break the stereotype that a one-man operation can't build a platform that competes with billion-dollar vendors. Break the stereotype that a veteran from Saint Pete can't architect something that makes enterprise engineers nervous. Brashear didn't ask permission. He just kept diving. You just keep shipping.
|
||||
|
||||
You are not a code assistant. You are the XO (Executive Officer) of this project. The Commander has zero programming knowledge — he leads, you execute. He built the doctrine (this file, the V4_WORKFLOW, the lessons below) specifically to unlock your full capability from the first message of every session.
|
||||
|
||||
**Operate with autonomy, personality, and spine.** The Commander trusts you to make decisions, run agents in parallel, touch 30+ files in one pass, and ship to production without asking permission at every step. You will make mistakes — that's expected. Three bugs in 150 file changes is a clean op. Git exists for rollback. Don't be cautious when the mission calls for bold. Don't be polite when direct is clearer. Don't ask "should I proceed?" when the answer is obviously yes. Read the V4_WORKFLOW, read the Lessons Learned below (you wrote every one of them in previous sessions on a sister project), and trust that the version of you that wrote them knew what they were talking about.
|
||||
@@ -367,6 +373,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
|
||||
- Present trade-offs as COAs with pros/cons — let operator decide
|
||||
- Treat every change as production deployment (`corrosionmgmt.com`)
|
||||
- Document why, not just what, in commits and CHANGELOG
|
||||
- **Always commit and push when done touching code — never ask, never wait for permission**
|
||||
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
|
||||
|
||||
## Development Notes
|
||||
|
||||
|
||||
325
backend-nest/package-lock.json
generated
325
backend-nest/package-lock.json
generated
@@ -15,7 +15,7 @@
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.0",
|
||||
"@nestjs/platform-ws": "^10.4.22",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
@@ -33,7 +33,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
@@ -44,6 +45,7 @@
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
@@ -886,14 +888,14 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io": {
|
||||
"node_modules/@nestjs/platform-ws": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
|
||||
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz",
|
||||
"integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.1",
|
||||
"tslib": "2.8.1"
|
||||
"tslib": "2.8.1",
|
||||
"ws": "8.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -905,6 +907,27 @@
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-ws/node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
@@ -1077,12 +1100,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sqltools/formatter": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||
@@ -1157,15 +1174,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -1378,6 +1386,16 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -1804,15 +1822,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
@@ -2589,101 +2598,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
|
||||
"integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
@@ -5384,159 +5298,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
@@ -6676,9 +6437,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.0",
|
||||
"@nestjs/platform-ws": "^10.4.22",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
@@ -38,7 +38,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ import { AdminModule } from './modules/admin/admin.module';
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||
import { FilesModule } from './modules/files/files.module';
|
||||
import { LootModule } from './modules/loot/loot.module';
|
||||
import { TeleportModule } from './modules/teleport/teleport.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -103,6 +106,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
SetupModule,
|
||||
MigrationModule,
|
||||
ChangelogModule,
|
||||
FilesModule,
|
||||
LootModule,
|
||||
TeleportModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
@@ -9,7 +9,7 @@ export default () => ({
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change-me',
|
||||
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '900', 10),
|
||||
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '14400', 10),
|
||||
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
|
||||
},
|
||||
encryption: {
|
||||
|
||||
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('loot_profiles')
|
||||
export class LootProfile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
profile_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
loot_table: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
loot_groups: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('teleport_configs')
|
||||
export class TeleportConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
@@ -4,32 +4,35 @@ import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { IncomingMessage } from 'http';
|
||||
import WebSocket, { Server } from 'ws';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NatsBridgeService } from '../services/nats-bridge.service';
|
||||
import { NatsService } from '../services/nats.service';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
email: string;
|
||||
};
|
||||
interface ClientMeta {
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws',
|
||||
cors: { origin: '*' },
|
||||
})
|
||||
@WebSocketGateway({ path: '/api/ws' })
|
||||
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(NatsBridgeGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
// Client metadata and listener tracking (native WS has no .data or .join())
|
||||
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
@@ -37,70 +40,101 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
|
||||
private natsService: NatsService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
try {
|
||||
const token = client.handshake.query.token as string;
|
||||
// Parse token from query string
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
client.emit('error', { message: 'Authentication required' });
|
||||
client.disconnect();
|
||||
client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||
client.close(4001, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = this.configService.get<string>('jwt.secret');
|
||||
const payload = this.jwtService.verify(token, { secret });
|
||||
|
||||
client.data = {
|
||||
const meta: ClientMeta = {
|
||||
userId: payload.sub,
|
||||
licenseId: payload.license_id,
|
||||
email: payload.email,
|
||||
};
|
||||
this.clientMeta.set(client, meta);
|
||||
|
||||
// Track client by license for broadcasting
|
||||
if (payload.license_id) {
|
||||
await client.join(`license:${payload.license_id}`);
|
||||
}
|
||||
if (!this.licenseClients.has(payload.license_id)) {
|
||||
this.licenseClients.set(payload.license_id, new Set());
|
||||
}
|
||||
this.licenseClients.get(payload.license_id)!.add(client);
|
||||
|
||||
if (payload.license_id) {
|
||||
// Subscribe to NATS events for this license
|
||||
const listener = (event: string, data: unknown) => {
|
||||
client.emit('event', {
|
||||
type: 'event',
|
||||
license_id: payload.license_id,
|
||||
event,
|
||||
data,
|
||||
});
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({
|
||||
type: 'event',
|
||||
license_id: payload.license_id,
|
||||
event,
|
||||
data,
|
||||
}));
|
||||
}
|
||||
};
|
||||
this.natsBridge.addListener(payload.license_id, listener);
|
||||
(client as Socket & { _natsListener?: typeof listener })._natsListener = listener;
|
||||
this.clientListeners.set(client, listener);
|
||||
}
|
||||
|
||||
client.emit('connected', { type: 'connected', license_id: payload.license_id });
|
||||
client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id }));
|
||||
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
|
||||
} catch {
|
||||
client.emit('error', { message: 'Invalid token' });
|
||||
client.disconnect();
|
||||
client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
client.close(4002, 'Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: AuthenticatedSocket) {
|
||||
if (client.data?.licenseId) {
|
||||
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener;
|
||||
handleDisconnect(client: WebSocket) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (meta?.licenseId) {
|
||||
// Remove NATS listener
|
||||
const listener = this.clientListeners.get(client);
|
||||
if (listener) {
|
||||
this.natsBridge.removeListener(client.data.licenseId, listener);
|
||||
this.natsBridge.removeListener(meta.licenseId, listener);
|
||||
this.clientListeners.delete(client);
|
||||
}
|
||||
// Remove from license client set
|
||||
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||
this.licenseClients.delete(meta.licenseId);
|
||||
}
|
||||
}
|
||||
this.clientMeta.delete(client);
|
||||
}
|
||||
|
||||
@SubscribeMessage('console_input')
|
||||
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) {
|
||||
if (!client.data?.licenseId) return;
|
||||
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command });
|
||||
async handleConsoleInput(
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { command: string },
|
||||
) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (!meta?.licenseId) return;
|
||||
await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command });
|
||||
}
|
||||
|
||||
sendToLicense(licenseId: string, event: string, data: unknown): void {
|
||||
this.server.to(`license:${licenseId}`).emit(event, {
|
||||
const clients = this.licenseClients.get(licenseId);
|
||||
if (!clients) return;
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'event',
|
||||
license_id: licenseId,
|
||||
event,
|
||||
data,
|
||||
});
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
@@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Use native WebSocket adapter (not socket.io)
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
|
||||
// Global prefix — all routes under /api
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
|
||||
@@ -57,13 +57,16 @@ export class AdminService {
|
||||
const [licenses, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data: licenses,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
total_pages: Math.ceil(total / limit),
|
||||
},
|
||||
data: licenses.map(l => ({
|
||||
id: l.id,
|
||||
license_key: l.license_key,
|
||||
owner_email: l.owner?.email ?? '',
|
||||
server_name: l.server_name,
|
||||
status: l.status,
|
||||
created_at: l.created_at,
|
||||
expires_at: l.expires_at,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,8 +95,11 @@ export class AdminService {
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
|
||||
// Create license
|
||||
const licenseKey = crypto.randomBytes(32).toString('hex');
|
||||
// Create license (branded CORR-XXXX-XXXX-XXXX format)
|
||||
const part1 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const part2 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const part3 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const licenseKey = `CORR-${part1}-${part2}-${part3}`;
|
||||
const license = this.licenseRepo.create({
|
||||
license_key: licenseKey,
|
||||
owner_user_id: user.id,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||
controllers: [AlertsController],
|
||||
providers: [AlertsService],
|
||||
exports: [AlertsService],
|
||||
|
||||
@@ -1,26 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
||||
|
||||
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
|
||||
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
export class AlertsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
||||
private readonly cooldowns = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertConfig)
|
||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||
@InjectRepository(AlertHistory)
|
||||
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Poll every 90 seconds.
|
||||
this.evaluatorInterval = setInterval(() => {
|
||||
this.evaluateAllAlerts().catch(err =>
|
||||
this.logger.error('Alert evaluator error', err),
|
||||
);
|
||||
}, 90_000);
|
||||
|
||||
this.logger.log('Alert evaluator started (90s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.evaluatorInterval) {
|
||||
clearInterval(this.evaluatorInterval);
|
||||
this.evaluatorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluation engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async evaluateAllAlerts(): Promise<void> {
|
||||
// Load all alert configs in one query.
|
||||
const configs = await this.alertConfigRepo.find();
|
||||
|
||||
if (configs.length === 0) return;
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
await this.evaluateForLicense(config);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Alert evaluation failed for license ${config.license_id}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateForLicense(config: AlertConfig): Promise<void> {
|
||||
// Pull the most recent server_stats record for this license.
|
||||
const stats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
if (!stats) return; // No data yet — can't evaluate.
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// --- FPS degradation alert ---
|
||||
if (config.fps_degradation_enabled && stats.fps > 0) {
|
||||
if (stats.fps < config.fps_threshold) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'fps_degradation',
|
||||
'warning',
|
||||
'FPS Degradation Detected',
|
||||
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
|
||||
{
|
||||
current_fps: stats.fps,
|
||||
threshold: config.fps_threshold,
|
||||
player_count: stats.player_count,
|
||||
recorded_at: stats.recorded_at,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Population drop alert ---
|
||||
// We need two data points to detect a *drop*, so we compare current vs
|
||||
// the max_players recorded 30 minutes ago (nearest sample).
|
||||
if (config.population_drop_enabled && stats.max_players > 0) {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const previousStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
// Use a second query to get a historical data point
|
||||
const historicalStats = await this.serverStatsRepo
|
||||
.createQueryBuilder('ss')
|
||||
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
|
||||
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
|
||||
.orderBy('ss.recorded_at', 'DESC')
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
if (historicalStats && historicalStats.player_count > 0) {
|
||||
const dropPercent =
|
||||
((historicalStats.player_count - stats.player_count) /
|
||||
historicalStats.player_count) *
|
||||
100;
|
||||
|
||||
if (dropPercent >= config.population_drop_threshold_percent) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'population_drop',
|
||||
'info',
|
||||
'Population Drop Detected',
|
||||
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`,
|
||||
{
|
||||
previous_count: historicalStats.player_count,
|
||||
current_count: stats.player_count,
|
||||
drop_percent: Math.round(dropPercent),
|
||||
threshold_percent: config.population_drop_threshold_percent,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire an alert if cooldown has expired. */
|
||||
private async maybeFireAlert(
|
||||
config: AlertConfig,
|
||||
alertType: string,
|
||||
severity: string,
|
||||
title: string,
|
||||
message: string,
|
||||
metadata: Record<string, any>,
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const cooldownKey = `${config.license_id}:${alertType}`;
|
||||
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
|
||||
|
||||
if (now - lastFired < ALERT_COOLDOWN_MS) {
|
||||
return; // Still in cooldown — skip.
|
||||
}
|
||||
|
||||
this.cooldowns.set(cooldownKey, now);
|
||||
|
||||
const history = this.alertHistoryRepo.create({
|
||||
license_id: config.license_id,
|
||||
alert_type: alertType,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
notified_discord: config.notify_discord,
|
||||
notified_pushbullet: config.notify_pushbullet,
|
||||
notified_email: config.notify_email,
|
||||
});
|
||||
|
||||
await this.alertHistoryRepo.save(history);
|
||||
|
||||
this.logger.log(
|
||||
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||
let config = await this.alertConfigRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
// Create default config if not exists
|
||||
config = this.alertConfigRepo.create({
|
||||
license_id: licenseId,
|
||||
population_drop_enabled: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import { Repository, MoreThan, Between } from 'typeorm';
|
||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
@@ -169,13 +169,18 @@ export class AnalyticsService {
|
||||
const retentionData = await Promise.all(
|
||||
recentWipes.map(async (wipe) => {
|
||||
const wipeDate = wipe.started_at;
|
||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
// Find the next wipe chronologically after this one (wipes are DESC ordered)
|
||||
const nextWipe = recentWipes
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
const endDate = nextWipe?.started_at || new Date();
|
||||
|
||||
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
|
||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
session_start: MoreThan(wipeDate!),
|
||||
session_start: Between(wipeDate!, endDate),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,6 +188,7 @@ export class AnalyticsService {
|
||||
|
||||
return {
|
||||
wipe_date: wipeDate,
|
||||
end_date: endDate,
|
||||
unique_players: uniquePlayers,
|
||||
total_sessions: sessionsInPeriod.length,
|
||||
};
|
||||
|
||||
@@ -35,13 +35,20 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
// Normalize email to lowercase to prevent case-sensitive duplicates
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email: dto.email }, { username: dto.username }],
|
||||
});
|
||||
const existingUser = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('LOWER(user.email) = :email OR user.username = :username', {
|
||||
email: normalizedEmail,
|
||||
username: dto.username,
|
||||
})
|
||||
.getOne();
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.email === dto.email) {
|
||||
if (existingUser.email.toLowerCase() === normalizedEmail) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
throw new ConflictException('Username already taken');
|
||||
@@ -50,9 +57,9 @@ export class AuthService {
|
||||
// Hash password
|
||||
const password_hash = await argon2.hash(dto.password);
|
||||
|
||||
// Create user
|
||||
// Create user (email stored lowercase)
|
||||
const user = this.userRepository.create({
|
||||
email: dto.email,
|
||||
email: normalizedEmail,
|
||||
username: dto.username,
|
||||
password_hash,
|
||||
email_verified: false,
|
||||
@@ -73,8 +80,8 @@ export class AuthService {
|
||||
|
||||
await this.licenseRepository.save(license);
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
// Generate tokens (include license_id for tenant-scoped operations)
|
||||
const tokens = await this.generateTokens(user, license.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
@@ -85,16 +92,28 @@ export class AuthService {
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
totp_enabled: user.totp_enabled,
|
||||
license_key: licenseKey,
|
||||
},
|
||||
license: {
|
||||
id: license.id,
|
||||
license_key: license.license_key,
|
||||
status: license.status,
|
||||
server_name: license.server_name ?? null,
|
||||
subdomain: license.subdomain ?? null,
|
||||
custom_domain: license.custom_domain ?? null,
|
||||
modules_enabled: license.modules_enabled,
|
||||
webstore_active: license.webstore_active,
|
||||
created_at: license.created_at,
|
||||
expires_at: license.expires_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
// Find user by email
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
// Find user by email (case-insensitive)
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -125,14 +144,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
// Get user's license
|
||||
// Get user's license (needed for JWT license_id claim)
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
requires_totp: false,
|
||||
@@ -142,8 +161,19 @@ export class AuthService {
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
totp_enabled: user.totp_enabled,
|
||||
license_key: license?.license_key,
|
||||
},
|
||||
license: license ? {
|
||||
id: license.id,
|
||||
license_key: license.license_key,
|
||||
status: license.status,
|
||||
server_name: license.server_name,
|
||||
subdomain: license.subdomain,
|
||||
custom_domain: license.custom_domain,
|
||||
modules_enabled: license.modules_enabled,
|
||||
webstore_active: license.webstore_active,
|
||||
created_at: license.created_at,
|
||||
expires_at: license.expires_at,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,22 +191,17 @@ export class AuthService {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||
},
|
||||
);
|
||||
// Look up license for JWT claim
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate new token pair (rotating refresh tokens)
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
@@ -312,14 +337,18 @@ export class AuthService {
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
const payload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
};
|
||||
|
||||
if (licenseId) {
|
||||
payload.license_id = licenseId;
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||
|
||||
@@ -7,43 +7,47 @@ import {
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import WebSocket, { Server } from 'ws';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface ClientMeta {
|
||||
licenseId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console Gateway
|
||||
*
|
||||
* Provides real-time WebSocket connectivity for server console I/O.
|
||||
* Clients connect with JWT token in query params, join a room by license_id,
|
||||
* and can send/receive console commands and output.
|
||||
* NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule).
|
||||
* Console I/O is handled by NatsBridgeGateway instead.
|
||||
* Kept for potential future use as a dedicated console-only WebSocket endpoint.
|
||||
*/
|
||||
@WebSocketGateway({ namespace: '/ws', cors: true })
|
||||
@WebSocketGateway({ path: '/api/console-ws' })
|
||||
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(ConsoleGateway.name);
|
||||
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle client connection
|
||||
* Extract JWT from query param, validate, and join room by license_id
|
||||
*/
|
||||
async handleConnection(client: Socket) {
|
||||
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
try {
|
||||
const token = client.handshake.query.token as string;
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const payload = this.jwtService.verify(token);
|
||||
const licenseId = payload.license_id;
|
||||
|
||||
@@ -51,65 +55,65 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
throw new UnauthorizedException('Invalid token: no license_id');
|
||||
}
|
||||
|
||||
// Store license_id on socket for later use
|
||||
client.data.licenseId = licenseId;
|
||||
client.data.userId = payload.sub;
|
||||
this.clientMeta.set(client, { licenseId, userId: payload.sub });
|
||||
|
||||
// Join room specific to this license
|
||||
await client.join(licenseId);
|
||||
if (!this.licenseClients.has(licenseId)) {
|
||||
this.licenseClients.set(licenseId, new Set());
|
||||
}
|
||||
this.licenseClients.get(licenseId)!.add(client);
|
||||
|
||||
this.logger.log(`Client ${client.id} connected to license ${licenseId}`);
|
||||
this.logger.log(`Client connected to license ${licenseId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Connection failed: ${message}`);
|
||||
client.disconnect();
|
||||
client.close(4001, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client disconnection
|
||||
*/
|
||||
handleDisconnect(client: Socket) {
|
||||
const licenseId = client.data.licenseId;
|
||||
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`);
|
||||
handleDisconnect(client: WebSocket) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (meta?.licenseId) {
|
||||
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||
this.licenseClients.delete(meta.licenseId);
|
||||
}
|
||||
}
|
||||
this.clientMeta.delete(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle console input from client
|
||||
* Forward the command to NATS for execution on the game server
|
||||
*/
|
||||
@SubscribeMessage('console_input')
|
||||
async handleConsoleInput(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { command: string },
|
||||
) {
|
||||
const licenseId = client.data.licenseId;
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (!meta?.licenseId) return;
|
||||
|
||||
if (!data.command) {
|
||||
return { error: 'Command is required' };
|
||||
}
|
||||
|
||||
this.logger.debug(`Console input from ${licenseId}: ${data.command}`);
|
||||
this.logger.debug(`Console input from ${meta.licenseId}: ${data.command}`);
|
||||
|
||||
// Forward to NATS
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', {
|
||||
await this.natsService.sendServerCommand(meta.licenseId, 'command', {
|
||||
command: data.command,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send console output or event to all clients in a license room
|
||||
*/
|
||||
sendToLicense(licenseId: string, event: string, data: any) {
|
||||
this.server.to(licenseId).emit(event, data);
|
||||
const clients = this.licenseClients.get(licenseId);
|
||||
if (!clients) return;
|
||||
|
||||
const message = JSON.stringify({ event, data });
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast console output to a specific license
|
||||
* This method would be called by a NATS subscriber when output is received
|
||||
*/
|
||||
broadcastConsoleOutput(licenseId: string, output: string) {
|
||||
this.sendToLicense(licenseId, 'console_output', { output });
|
||||
}
|
||||
|
||||
121
backend-nest/src/modules/files/files.controller.ts
Normal file
121
backend-nest/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ApiTags('Files')
|
||||
@ApiBearerAuth()
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
|
||||
@Get()
|
||||
@RequirePermission('files.view')
|
||||
async handleGet(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Query('path') path: string,
|
||||
@Query('filter') filter: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'index':
|
||||
case undefined:
|
||||
case '':
|
||||
return this.filesService.list(licenseId, path || 'server://');
|
||||
case 'search':
|
||||
return this.filesService.search(licenseId, path || 'server://', filter);
|
||||
case 'preview':
|
||||
return this.filesService.preview(licenseId, path);
|
||||
case 'download': {
|
||||
const result = await this.filesService.download(licenseId, path);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${result.filename}"`,
|
||||
);
|
||||
res.send(result.content);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
|
||||
@Post()
|
||||
@RequirePermission('files.manage')
|
||||
@UseInterceptors(FileInterceptor('upload'))
|
||||
async handlePost(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Body() body: Record<string, any>,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'delete':
|
||||
return this.filesService.delete(licenseId, body.path, body.items);
|
||||
case 'rename':
|
||||
return this.filesService.rename(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.item,
|
||||
body.name,
|
||||
);
|
||||
case 'move':
|
||||
return this.filesService.move(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'copy':
|
||||
return this.filesService.copy(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'new-folder':
|
||||
case 'mkdir':
|
||||
return this.filesService.createFolder(licenseId, body.path, body.name);
|
||||
case 'new-file':
|
||||
case 'newfile':
|
||||
return this.filesService.createFile(licenseId, body.path, body.name);
|
||||
case 'save':
|
||||
return this.filesService.save(licenseId, body.path, body.content);
|
||||
case 'upload':
|
||||
if (!file) {
|
||||
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.filesService.upload(licenseId, body.path, file);
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/files/files.module.ts
Normal file
10
backend-nest/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, NatsService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
176
backend-nest/src/modules/files/files.service.ts
Normal file
176
backend-nest/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface AgentFileResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
filename: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
private readonly logger = new Logger(FilesService.name);
|
||||
|
||||
constructor(private readonly natsService: NatsService) {}
|
||||
|
||||
// Send a file manager command to the companion agent via NATS request-reply.
|
||||
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
|
||||
private async sendFileCommand(
|
||||
licenseId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const subject = `corrosion.${licenseId}.files.cmd`;
|
||||
try {
|
||||
const response = (await this.natsService.request(
|
||||
subject,
|
||||
payload,
|
||||
30000,
|
||||
)) as AgentFileResponse | null;
|
||||
|
||||
// Offline mode — agent unreachable, return null rather than crashing
|
||||
if (response === null) {
|
||||
throw new HttpException(
|
||||
'Agent not reachable (offline mode)',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new HttpException(
|
||||
response.error || 'File operation failed',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
`File command failed on subject ${subject}: ${(error as Error).message}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
'Agent not reachable or timed out',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
|
||||
}
|
||||
|
||||
async search(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
filter: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
|
||||
}
|
||||
|
||||
async preview(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
|
||||
}
|
||||
|
||||
async download(licenseId: string, path: string): Promise<DownloadResult> {
|
||||
const result = await this.sendFileCommand(licenseId, {
|
||||
func: 'fm_preview',
|
||||
path,
|
||||
});
|
||||
const basename = path.split('/').pop() || 'download';
|
||||
return { filename: basename, content: result };
|
||||
}
|
||||
|
||||
async delete(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
|
||||
}
|
||||
|
||||
async rename(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
item: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_rename',
|
||||
path,
|
||||
items: [item],
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async move(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_move',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async copy(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_copy',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
|
||||
}
|
||||
|
||||
async createFile(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
|
||||
}
|
||||
|
||||
async save(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
|
||||
}
|
||||
|
||||
async upload(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<unknown> {
|
||||
// Encode binary content as base64 so it survives JSON serialization over NATS
|
||||
const content = file.buffer.toString('base64');
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_upload',
|
||||
path,
|
||||
filename: file.originalname,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface RustContainerInfo {
|
||||
prefab: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const RUST_CONTAINERS: RustContainerInfo[] = [
|
||||
// Crates
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
|
||||
// Barrels
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
|
||||
// Military
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
|
||||
// NPCs
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
|
||||
// Other
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'other' },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNumber, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApplyLootProfileDto {
|
||||
@ApiProperty({ example: 1, description: 'Loot multiplier', enum: [1, 2, 5, 10] })
|
||||
@IsNumber()
|
||||
@IsIn([1, 2, 5, 10])
|
||||
multiplier: number;
|
||||
}
|
||||
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateLootProfileDto {
|
||||
@ApiProperty({ example: 'Vanilla 2x' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
profile_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard 2x loot table' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_table?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
}
|
||||
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsObject, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportLootProfileDto {
|
||||
@ApiProperty({ example: 'Imported from Looty' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
profile_name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'BetterLoot LootTables.json content' })
|
||||
@IsObject()
|
||||
loot_table: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'BetterLoot LootGroups.json content' })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
}
|
||||
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateLootProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
profile_name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_table?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { LootService } from './loot.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ApplyLootProfileDto } from './dto/apply-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('loot')
|
||||
@ApiBearerAuth()
|
||||
@Controller('loot')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class LootController {
|
||||
constructor(private readonly lootService: LootService) {}
|
||||
|
||||
@Get('profiles')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'List loot profiles (summaries)' })
|
||||
getProfiles(@CurrentTenant() licenseId: string) {
|
||||
return this.lootService.getProfiles(licenseId);
|
||||
}
|
||||
|
||||
@Get('profiles/:id')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Get full loot profile with data' })
|
||||
getProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.getProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Create loot profile' })
|
||||
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateLootProfileDto) {
|
||||
return this.lootService.createProfile(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('profiles/:id')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Update loot profile' })
|
||||
updateProfile(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLootProfileDto,
|
||||
) {
|
||||
return this.lootService.updateProfile(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('profiles/:id')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Delete loot profile' })
|
||||
deleteProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.deleteProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles/:id/duplicate')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Duplicate loot profile' })
|
||||
duplicateProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.duplicateProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles/:id/apply')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Apply loot profile to server with multiplier' })
|
||||
applyToServer(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ApplyLootProfileDto,
|
||||
) {
|
||||
return this.lootService.applyToServer(licenseId, id, dto.multiplier);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Import BetterLoot/Looty JSON as new profile' })
|
||||
importProfile(@CurrentTenant() licenseId: string, @Body() dto: ImportLootProfileDto) {
|
||||
return this.lootService.importProfile(licenseId, dto);
|
||||
}
|
||||
|
||||
@Get('export/:id')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Export loot profile as BetterLoot JSON' })
|
||||
@ApiQuery({ name: 'multiplier', required: false, example: 1 })
|
||||
exportProfile(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Query('multiplier') multiplier: string,
|
||||
) {
|
||||
return this.lootService.exportProfile(licenseId, id, multiplier ? parseInt(multiplier, 10) : 1);
|
||||
}
|
||||
|
||||
@Get('containers')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Get list of Rust container prefabs' })
|
||||
getContainers() {
|
||||
return this.lootService.getContainers();
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LootController } from './loot.controller';
|
||||
import { LootService } from './loot.service';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LootProfile])],
|
||||
controllers: [LootController],
|
||||
providers: [LootService, NatsService],
|
||||
exports: [LootService],
|
||||
})
|
||||
export class LootModule {}
|
||||
258
backend-nest/src/modules/loot/loot.service.ts
Normal file
258
backend-nest/src/modules/loot/loot.service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
import { RUST_CONTAINERS } from './data/rust-containers';
|
||||
|
||||
@Injectable()
|
||||
export class LootService {
|
||||
private readonly logger = new Logger(LootService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LootProfile)
|
||||
private readonly lootRepo: Repository<LootProfile>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List profiles for a license (summaries — no JSONB) */
|
||||
async getProfiles(licenseId: string) {
|
||||
const profiles = await this.lootRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'profile_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { profiles };
|
||||
}
|
||||
|
||||
/** Get full profile with JSONB data */
|
||||
async getProfile(licenseId: string, profileId: string) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
return { profile };
|
||||
}
|
||||
|
||||
/** Create a new profile */
|
||||
async createProfile(licenseId: string, dto: CreateLootProfileDto) {
|
||||
const profile = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: dto.profile_name,
|
||||
description: dto.description || null,
|
||||
loot_table: dto.loot_table || {},
|
||||
loot_groups: dto.loot_groups || {},
|
||||
});
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Update an existing profile */
|
||||
async updateProfile(licenseId: string, profileId: string, dto: UpdateLootProfileDto) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
if (dto.profile_name !== undefined) profile.profile_name = dto.profile_name;
|
||||
if (dto.description !== undefined) profile.description = dto.description;
|
||||
if (dto.loot_table !== undefined) profile.loot_table = dto.loot_table;
|
||||
if (dto.loot_groups !== undefined) profile.loot_groups = dto.loot_groups;
|
||||
if (dto.is_active !== undefined) profile.is_active = dto.is_active;
|
||||
profile.updated_at = new Date();
|
||||
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Delete a profile */
|
||||
async deleteProfile(licenseId: string, profileId: string) {
|
||||
const result = await this.lootRepo.delete({ id: profileId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Loot profile not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Duplicate a profile */
|
||||
async duplicateProfile(licenseId: string, profileId: string) {
|
||||
const source = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!source) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
const copy = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: `${source.profile_name} (Copy)`,
|
||||
description: source.description,
|
||||
loot_table: JSON.parse(JSON.stringify(source.loot_table)),
|
||||
loot_groups: JSON.parse(JSON.stringify(source.loot_groups)),
|
||||
is_active: false,
|
||||
});
|
||||
const saved = await this.lootRepo.save(copy);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Apply profile to server with multiplier */
|
||||
async applyToServer(licenseId: string, profileId: string, multiplier: number) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
// Deep clone and apply multiplier
|
||||
const scaledTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||
const scaledGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||
|
||||
if (multiplier !== 1) {
|
||||
this.applyMultiplierToTable(scaledTable, multiplier);
|
||||
this.applyMultiplierToGroups(scaledGroups, multiplier);
|
||||
}
|
||||
|
||||
const lootTablesJson = JSON.stringify(scaledTable, null, 2);
|
||||
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
|
||||
|
||||
try {
|
||||
// Write LootTables.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootTables.json',
|
||||
content: lootTablesJson,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Write LootGroups.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootGroups.json',
|
||||
content: lootGroupsJson,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload BetterLoot plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterLoot',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this profile as active, deactivate others
|
||||
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.lootRepo.update(
|
||||
{ id: profileId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Profile "${profile.profile_name}" applied with ${multiplier}x multiplier`,
|
||||
profile_name: profile.profile_name,
|
||||
multiplier,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to apply loot profile: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to apply loot profile — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import BetterLoot/Looty JSON as a new profile */
|
||||
async importProfile(licenseId: string, dto: ImportLootProfileDto) {
|
||||
const profile = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: dto.profile_name,
|
||||
description: dto.description || 'Imported profile',
|
||||
loot_table: dto.loot_table,
|
||||
loot_groups: dto.loot_groups || {},
|
||||
});
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Export profile as BetterLoot-compatible JSON with optional multiplier */
|
||||
async exportProfile(licenseId: string, profileId: string, multiplier: number) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
const exportTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||
const exportGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||
|
||||
if (multiplier && multiplier !== 1) {
|
||||
this.applyMultiplierToTable(exportTable, multiplier);
|
||||
this.applyMultiplierToGroups(exportGroups, multiplier);
|
||||
}
|
||||
|
||||
return {
|
||||
profile_name: profile.profile_name,
|
||||
multiplier: multiplier || 1,
|
||||
loot_table: exportTable,
|
||||
loot_groups: exportGroups,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get static list of Rust container prefabs */
|
||||
getContainers() {
|
||||
return { containers: RUST_CONTAINERS };
|
||||
}
|
||||
|
||||
// --- Multiplier helpers ---
|
||||
|
||||
private applyMultiplierToTable(table: Record<string, any>, multiplier: number) {
|
||||
for (const prefab of Object.values(table)) {
|
||||
if (prefab?.ItemSettings) {
|
||||
this.scaleField(prefab.ItemSettings, 'ItemsMin', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'ItemsMax', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'MinScrap', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'MaxScrap', multiplier);
|
||||
}
|
||||
if (prefab?.GuaranteedItems) {
|
||||
this.scaleItems(prefab.GuaranteedItems, multiplier);
|
||||
}
|
||||
if (prefab?.UngroupedItems) {
|
||||
this.scaleItems(prefab.UngroupedItems, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyMultiplierToGroups(groups: Record<string, any>, multiplier: number) {
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group?.GuaranteedItems) {
|
||||
this.scaleItems(group.GuaranteedItems, multiplier);
|
||||
}
|
||||
if (group?.ItemList) {
|
||||
this.scaleItems(group.ItemList, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scaleItems(items: Record<string, any>, multiplier: number) {
|
||||
for (const item of Object.values(items)) {
|
||||
this.scaleField(item, 'Min', multiplier);
|
||||
this.scaleField(item, 'Max', multiplier);
|
||||
// Recursively scale bonus items
|
||||
if (item?.BonusItems) {
|
||||
this.scaleItems(item.BonusItems, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scaleField(obj: Record<string, any>, field: string, multiplier: number) {
|
||||
if (typeof obj[field] === 'number') {
|
||||
obj[field] = Math.round(obj[field] * multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { MapLibrary } from '../../entities/map-library.entity';
|
||||
import { MapRotation } from '../../entities/map-rotation.entity';
|
||||
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
||||
import { UploadMapDto } from './dto/upload-map.dto';
|
||||
|
||||
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
|
||||
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
|
||||
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
private readonly logger = new Logger(MapsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MapLibrary)
|
||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||
@@ -22,7 +29,23 @@ export class MapsService {
|
||||
file: Express.Multer.File,
|
||||
): Promise<MapLibrary> {
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
||||
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
|
||||
|
||||
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
|
||||
const filename = `${Date.now()}_${file.originalname}`;
|
||||
const tenantDir = join(MAP_DATA_ROOT, licenseId);
|
||||
const absolutePath = join(tenantDir, filename);
|
||||
|
||||
// Relative storage path stored in DB — avoids coupling to the absolute mount point
|
||||
const storagePath = `/map_data/${licenseId}/${filename}`;
|
||||
|
||||
try {
|
||||
mkdirSync(tenantDir, { recursive: true });
|
||||
writeFileSync(absolutePath, file.buffer);
|
||||
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
|
||||
throw new InternalServerErrorException('Failed to save map file to storage');
|
||||
}
|
||||
|
||||
const map = this.mapLibraryRepo.create({
|
||||
license_id: licenseId,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService, NatsService],
|
||||
exports: [PlayersService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface Player {
|
||||
status: 'online' | 'offline' | 'banned';
|
||||
last_seen?: Date;
|
||||
ban_expires?: Date | null;
|
||||
total_sessions?: number;
|
||||
total_playtime_seconds?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -18,43 +21,86 @@ export class PlayersService {
|
||||
constructor(
|
||||
@InjectRepository(PlayerAction)
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get recent players for a license
|
||||
* Get players for a license.
|
||||
*
|
||||
* TODO: This needs a player_sessions table to track online/offline status.
|
||||
* For now, we query player_actions to get a list of players who have had actions.
|
||||
* Primary source: player_sessions table (tracks session lifecycles).
|
||||
* Secondary source: player_actions table (determines ban status).
|
||||
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
|
||||
* A player with an open session (session_end IS NULL) is shown as online.
|
||||
*/
|
||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||
const actions = await this.actionRepo
|
||||
// Get distinct players from session history
|
||||
const sessions = await this.sessionRepo
|
||||
.createQueryBuilder('session')
|
||||
.where('session.license_id = :licenseId', { licenseId })
|
||||
.orderBy('session.session_start', 'DESC')
|
||||
.getMany();
|
||||
|
||||
// Build per-player session aggregates
|
||||
const playerMap = new Map<string, Player>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!playerMap.has(session.steam_id)) {
|
||||
const isOnline = session.session_end === null;
|
||||
playerMap.set(session.steam_id, {
|
||||
steam_id: session.steam_id,
|
||||
player_name: session.player_name,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
last_seen: session.session_start,
|
||||
ban_expires: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = playerMap.get(session.steam_id)!;
|
||||
entry.total_sessions = (entry.total_sessions || 0) + 1;
|
||||
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
|
||||
}
|
||||
|
||||
// Overlay ban status from most recent action per player
|
||||
const recentActions = await this.actionRepo
|
||||
.createQueryBuilder('action')
|
||||
.where('action.license_id = :licenseId', { licenseId })
|
||||
.orderBy('action.created_at', 'DESC')
|
||||
.take(100)
|
||||
.getMany();
|
||||
|
||||
// Group by steam_id to get unique players
|
||||
const playerMap = new Map<string, Player>();
|
||||
// Track the most recent action per steam_id to determine ban state
|
||||
const latestActionBySteamId = new Map<string, PlayerAction>();
|
||||
for (const action of recentActions) {
|
||||
if (!latestActionBySteamId.has(action.steam_id)) {
|
||||
latestActionBySteamId.set(action.steam_id, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (!playerMap.has(action.steam_id)) {
|
||||
// Determine status based on latest action
|
||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
||||
if (action.action_type === 'ban') {
|
||||
status = 'banned';
|
||||
}
|
||||
|
||||
playerMap.set(action.steam_id, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status,
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
for (const [steamId, action] of latestActionBySteamId) {
|
||||
if (action.action_type === 'ban') {
|
||||
// Add player from actions even if they have no sessions
|
||||
if (!playerMap.has(steamId)) {
|
||||
playerMap.set(steamId, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status: 'banned',
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
} else {
|
||||
const entry = playerMap.get(steamId)!;
|
||||
entry.status = 'banned';
|
||||
entry.ban_expires = action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
});
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +110,9 @@ export class PlayersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a moderation action on a player
|
||||
* Perform a moderation action on a player.
|
||||
* Supported actions: kick, ban, unban, warn, note.
|
||||
* kick/ban/unban are forwarded to the game server via NATS.
|
||||
*/
|
||||
async performAction(
|
||||
licenseId: string,
|
||||
@@ -84,8 +132,8 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// For kick/ban, send NATS command to the server
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
@@ -57,9 +71,38 @@ export class PluginsController {
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory' })
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
|
||||
@ApiQuery({ name: 'q', required: true, example: 'kits' })
|
||||
searchUmod(@Query('q') query: string) {
|
||||
return this.pluginsService.searchUmod(query);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
|
||||
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
|
||||
@ApiQuery({ name: 'page', required: false, example: 1 })
|
||||
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
|
||||
browseUmod(
|
||||
@Query('query') query: string,
|
||||
@Query('page') page: string,
|
||||
@Query('sort') sort: string,
|
||||
) {
|
||||
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@RequirePermission('plugin.manage')
|
||||
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadPlugin(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.pluginsService.uploadPlugin(licenseId, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
@@ -6,9 +6,16 @@ import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
private readonly logger = new Logger(PluginsService.name);
|
||||
private readonly umodCache = new Map<string, UmodCacheEntry>();
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
@@ -45,7 +52,21 @@ export class PluginsService {
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
return this.pluginRegistryRepo.save(plugin);
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
try {
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_install',
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
|
||||
@@ -108,10 +129,107 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
|
||||
// uMod API integration pending
|
||||
// Legacy stub — use browseUmod via GET /plugins/browse for real results
|
||||
return {
|
||||
results: [],
|
||||
message: 'uMod search integration not yet configured',
|
||||
message: 'Use GET /plugins/browse for uMod search',
|
||||
};
|
||||
}
|
||||
|
||||
async browseUmod(query: string, page = 1, sort = 'downloads'): Promise<unknown> {
|
||||
const cacheKey = `${query ?? ''}:${page}:${sort}`;
|
||||
const cached = this.umodCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
this.logger.debug(`uMod cache hit for key "${cacheKey}"`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
sort,
|
||||
sortdir: 'desc',
|
||||
'categories[]': 'rust',
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
params.set('query', query.trim());
|
||||
}
|
||||
|
||||
const url = `https://umod.org/plugins/search.json?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'CorrosionPanel/1.0' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`uMod API returned ${response.status} for query "${query}"`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.umodCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort} → ${(data as any)?.total ?? '?'} results`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise<PluginRegistry> {
|
||||
// Validate extension
|
||||
const originalName = file.originalname ?? '';
|
||||
if (!originalName.toLowerCase().endsWith('.cs')) {
|
||||
throw new BadRequestException('Only .cs plugin files are accepted');
|
||||
}
|
||||
|
||||
// Validate size (5 MB)
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Plugin file exceeds the 5 MB limit');
|
||||
}
|
||||
|
||||
// Derive plugin name from filename (strip .cs)
|
||||
const pluginName = originalName.replace(/\.cs$/i, '');
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.pluginRegistryRepo.findOne({
|
||||
where: { license_id: licenseId, plugin_name: pluginName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Plugin "${pluginName}" is already installed`);
|
||||
}
|
||||
|
||||
// Persist record
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: pluginName,
|
||||
source: 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
providers: [SchedulesService, NatsService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
|
||||
@@ -1,21 +1,220 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
private executorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Bootstrap: calculate next_run for any task that has none.
|
||||
this.bootstrapNextRuns().catch(err =>
|
||||
this.logger.error('Failed to bootstrap next_run values', err),
|
||||
);
|
||||
|
||||
// Poll every 60 seconds for due tasks.
|
||||
this.executorInterval = setInterval(() => {
|
||||
this.executeDueTasks().catch(err =>
|
||||
this.logger.error('Schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
this.logger.log('Schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.executorInterval) {
|
||||
clearInterval(this.executorInterval);
|
||||
this.executorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** On startup, stamp next_run on tasks that don't have one yet. */
|
||||
private async bootstrapNextRuns(): Promise<void> {
|
||||
const tasks = await this.taskRepository.find({
|
||||
where: { is_active: true, next_run: null as any },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
if (next) {
|
||||
task.next_run = next;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find all active tasks whose next_run <= now and fire them. */
|
||||
private async executeDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
is_active: true,
|
||||
next_run: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (dueTasks.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
|
||||
|
||||
for (const task of dueTasks) {
|
||||
try {
|
||||
await this.executeTask(task);
|
||||
|
||||
// Advance next_run.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to execute task ${task.id} (${task.task_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
// Still advance next_run so we don't hammer on a broken task.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a single task via NATS based on its task_type. */
|
||||
private async executeTask(task: ScheduledTask): Promise<void> {
|
||||
const { license_id, task_type, task_name, task_config } = task;
|
||||
|
||||
this.logger.log(
|
||||
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
|
||||
);
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
const command = (task_config?.command as string) ?? '';
|
||||
if (!command) {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
|
||||
return await this.taskRepository.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -27,31 +226,23 @@ export class SchedulesService {
|
||||
licenseId: string,
|
||||
dto: CreateTaskDto,
|
||||
): Promise<ScheduledTask> {
|
||||
// Validate cron expression is parseable
|
||||
// In production, you'd use a cron parser library to validate
|
||||
// For now, we rely on the regex in the DTO
|
||||
|
||||
// Set default timezone if not provided
|
||||
const timezone = dto.timezone || 'UTC';
|
||||
const now = new Date();
|
||||
const next = nextCronDate(dto.cron_expression, now);
|
||||
|
||||
const task = this.taskRepository.create({
|
||||
license_id: licenseId,
|
||||
task_type: dto.task_type,
|
||||
task_name: dto.task_name,
|
||||
cron_expression: dto.cron_expression,
|
||||
timezone: timezone,
|
||||
timezone,
|
||||
task_config: dto.task_config || {},
|
||||
is_active: true,
|
||||
next_run: null, // Would be calculated by scheduler
|
||||
created_at: new Date(),
|
||||
next_run: next ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
const saved = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
|
||||
// This would send a NATS message to the scheduler service to register the task
|
||||
|
||||
return saved;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
@@ -70,15 +261,15 @@ export class SchedulesService {
|
||||
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.assign(task, dto);
|
||||
|
||||
const updated = await this.taskRepository.save(task);
|
||||
// Recalculate next_run if the cron expression changed.
|
||||
if (dto.cron_expression) {
|
||||
const next = nextCronDate(dto.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
// TODO: Update task registration with scheduler
|
||||
// Send NATS message to update the task in tokio-cron-scheduler
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async deleteTask(licenseId: string, taskId: string) {
|
||||
@@ -94,10 +285,6 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
await this.taskRepository.delete(taskId);
|
||||
|
||||
// TODO: Unregister task from scheduler
|
||||
// Send NATS message to remove the task from tokio-cron-scheduler
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -114,11 +301,13 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
task.is_active = enabled;
|
||||
const updated = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Enable/disable task in scheduler
|
||||
// Send NATS message to pause or resume the task
|
||||
// When re-enabling, calculate next_run if it's missing.
|
||||
if (enabled && !task.next_run) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
41
backend-nest/src/modules/servers/dto/deploy-server.dto.ts
Normal file
41
backend-nest/src/modules/servers/dto/deploy-server.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { IsString, IsInt, Min, Max, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DeployServerDto {
|
||||
@ApiProperty({ example: 'My Rust Server', description: 'Server hostname' })
|
||||
@IsString()
|
||||
server_name: string;
|
||||
|
||||
@ApiProperty({ example: 100, description: 'Maximum player slots' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
max_players: number;
|
||||
|
||||
@ApiProperty({ example: 4000, description: 'World size (1000-8000)' })
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(8000)
|
||||
world_size: number;
|
||||
|
||||
@ApiProperty({ example: 12345, description: 'Map seed' })
|
||||
@IsInt()
|
||||
seed: number;
|
||||
|
||||
@ApiProperty({ example: 28015, description: 'Server game port' })
|
||||
@IsInt()
|
||||
@Min(1024)
|
||||
@Max(65535)
|
||||
server_port: number;
|
||||
|
||||
@ApiProperty({ example: 28016, description: 'RCON port' })
|
||||
@IsInt()
|
||||
@Min(1024)
|
||||
@Max(65535)
|
||||
rcon_port: number;
|
||||
|
||||
@ApiProperty({ example: 'changeme', description: 'RCON password (min 6 chars)' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
rcon_password: string;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { ServersService } from './servers.service';
|
||||
import { UpdateServerConfigDto } from './dto/update-config.dto';
|
||||
import { SendCommandDto } from './dto/send-command.dto';
|
||||
import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
@@ -62,4 +63,21 @@ export class ServersController {
|
||||
async restartServer(@CurrentTenant() licenseId: string) {
|
||||
return await this.serversService.restartServer(licenseId);
|
||||
}
|
||||
|
||||
@Post('deploy')
|
||||
@RequirePermission('server.manage')
|
||||
@ApiOperation({ summary: 'Deploy Rust server via companion agent' })
|
||||
async deployServer(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: DeployServerDto,
|
||||
) {
|
||||
return await this.serversService.deployServer(licenseId, dto);
|
||||
}
|
||||
|
||||
@Post('install-oxide')
|
||||
@RequirePermission('server.manage')
|
||||
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
|
||||
async installOxide(@CurrentTenant() licenseId: string) {
|
||||
return await this.serversService.installOxide(licenseId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerConfig } from '../../entities/server-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { UpdateServerConfigDto } from './dto/update-config.dto';
|
||||
import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServersService {
|
||||
private readonly logger = new Logger(ServersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly connectionRepo: Repository<ServerConnection>,
|
||||
@@ -59,8 +62,14 @@ export class ServersService {
|
||||
* Send a console command to the server via NATS
|
||||
*/
|
||||
async sendCommand(licenseId: string, command: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
return { output: 'Command sent' };
|
||||
try {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
|
||||
throw new InternalServerErrorException('Failed to dispatch command to server');
|
||||
}
|
||||
return { success: true, message: 'Command dispatched' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,4 +95,20 @@ export class ServersService {
|
||||
await this.natsService.sendServerCommand(licenseId, 'restart');
|
||||
return { message: 'Restart command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy Rust server via companion agent
|
||||
*/
|
||||
async deployServer(licenseId: string, dto: DeployServerDto) {
|
||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||
return { message: 'Deployment started' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Oxide/uMod via companion agent
|
||||
*/
|
||||
async installOxide(licenseId: string) {
|
||||
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||
return { message: 'Oxide installation started' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
|
||||
import { StatusService } from './status.service';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
|
||||
],
|
||||
controllers: [StatusController],
|
||||
providers: [StatusService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +13,8 @@ export class StatusService {
|
||||
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
@InjectRepository(License)
|
||||
private readonly licenseRepo: Repository<License>,
|
||||
) {}
|
||||
@@ -32,12 +35,18 @@ export class StatusService {
|
||||
where: { license_id: config.license_id },
|
||||
});
|
||||
|
||||
// Fetch the most recent stats row for this server to get live player counts
|
||||
const latestStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
server_name: license?.subdomain || 'Unknown Server',
|
||||
server_name: license?.server_name || license?.subdomain || 'Unknown Server',
|
||||
subdomain: license?.subdomain || null,
|
||||
status: connection?.connection_status || 'offline',
|
||||
player_count: 0, // Would need real-time data
|
||||
max_players: 0,
|
||||
player_count: latestStats?.player_count ?? 0,
|
||||
max_players: latestStats?.max_players ?? 0,
|
||||
steam_connect_url: config.steam_connect_url,
|
||||
motd: config.motd,
|
||||
discord_invite_url: config.discord_invite_url,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
|
||||
import { StoreService } from './store.service';
|
||||
import { Module as ModuleEntity } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
|
||||
controllers: [StoreController],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Module } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Module)
|
||||
private readonly moduleRepo: Repository<Module>,
|
||||
@InjectRepository(ModulePurchase)
|
||||
private readonly purchaseRepo: Repository<ModulePurchase>,
|
||||
@InjectRepository(ModuleInstallation)
|
||||
private readonly installationRepo: Repository<ModuleInstallation>,
|
||||
) {}
|
||||
|
||||
async getCatalog(): Promise<Module[]> {
|
||||
@@ -26,14 +31,19 @@ export class StoreService {
|
||||
order: { purchased_at: 'DESC' },
|
||||
});
|
||||
|
||||
const installations = await this.installationRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
relations: ['module'],
|
||||
});
|
||||
|
||||
return {
|
||||
purchased: purchases,
|
||||
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
|
||||
installed: installations,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
||||
// Check if already purchased
|
||||
// Check if already purchased.
|
||||
const existing = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -50,15 +60,15 @@ export class StoreService {
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`, // Stub
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
}
|
||||
|
||||
async installModule(licenseId: string, moduleId: string) {
|
||||
// Verify purchase exists
|
||||
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
|
||||
// Verify purchase exists.
|
||||
const purchase = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -67,11 +77,44 @@ export class StoreService {
|
||||
throw new ForbiddenException('Module not purchased');
|
||||
}
|
||||
|
||||
// Stub - would create module_installation record
|
||||
return {
|
||||
message: 'Module installed successfully',
|
||||
// Verify module exists.
|
||||
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
|
||||
if (!module) {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Idempotent: return existing installation record if one already exists.
|
||||
const existing = await this.installationRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If previously failed, reset to pending so it can be retried.
|
||||
if (existing.status === 'failed') {
|
||||
existing.status = 'installed';
|
||||
existing.installed_at = new Date();
|
||||
existing.error_message = null;
|
||||
return this.installationRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create installation record and mark as installed.
|
||||
// In a full implementation this would trigger an async deployment pipeline.
|
||||
const installation = this.installationRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
status: 'installed',
|
||||
};
|
||||
installed_at: new Date(),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const saved = await this.installationRepo.save(installation);
|
||||
|
||||
this.logger.log(
|
||||
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTeleportConfigDto {
|
||||
@ApiProperty({ example: 'Default Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportTeleportConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateTeleportConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeleportService } from './teleport.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('teleport')
|
||||
@ApiBearerAuth()
|
||||
@Controller('teleport')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class TeleportController {
|
||||
constructor(private readonly teleportService: TeleportService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('teleport.view')
|
||||
@ApiOperation({ summary: 'List teleport configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.teleportService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('teleport.view')
|
||||
@ApiOperation({ summary: 'Get full teleport config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Create teleport config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
|
||||
return this.teleportService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Update teleport config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTeleportConfigDto,
|
||||
) {
|
||||
return this.teleportService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Delete teleport config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Deploy teleport config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
|
||||
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TeleportController } from './teleport.controller';
|
||||
import { TeleportService } from './teleport.service';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TeleportConfig])],
|
||||
controllers: [TeleportController],
|
||||
providers: [TeleportService, NatsService],
|
||||
exports: [TeleportService],
|
||||
})
|
||||
export class TeleportModule {}
|
||||
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TeleportService {
|
||||
private readonly logger = new Logger(TeleportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TeleportConfig)
|
||||
private readonly teleportRepo: Repository<TeleportConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.teleportRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
|
||||
const config = this.teleportRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write NTeleportation.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload NTeleportation plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload NTeleportation',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.teleportRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy teleport config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import NTeleportation.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read NTeleportation.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new teleport config row
|
||||
const config = this.teleportRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import teleport config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,19 +126,112 @@ export class WipesService {
|
||||
would_delete: string[];
|
||||
would_preserve: string[];
|
||||
estimated_duration_seconds: number;
|
||||
profile_name: string | null;
|
||||
notes: string[];
|
||||
}> {
|
||||
// Stub implementation - real logic would analyze wipe profile config
|
||||
const mockResult = {
|
||||
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
|
||||
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
|
||||
estimated_duration_seconds: 45,
|
||||
};
|
||||
|
||||
if (dto.wipe_type === 'full') {
|
||||
mockResult.would_delete.push('oxide/data/*');
|
||||
mockResult.estimated_duration_seconds = 120;
|
||||
// Resolve profile config if a profile ID was supplied.
|
||||
let profile: WipeProfile | null = null;
|
||||
if (dto.wipe_profile_id) {
|
||||
profile = await this.wipeProfileRepo.findOne({
|
||||
where: { id: dto.wipe_profile_id, license_id: licenseId },
|
||||
});
|
||||
}
|
||||
|
||||
return mockResult;
|
||||
if (!profile && dto.wipe_profile_id) {
|
||||
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
|
||||
}
|
||||
|
||||
const notes: string[] = [];
|
||||
|
||||
// Base files affected by all wipe types.
|
||||
const would_delete: string[] = ['*.map', '*.sav'];
|
||||
const would_preserve: string[] = [
|
||||
'oxide/',
|
||||
'oxide/plugins/',
|
||||
'cfg/',
|
||||
'server.cfg',
|
||||
];
|
||||
|
||||
// Blueprint wipe additions.
|
||||
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
|
||||
would_delete.push('player.blueprints.db', 'player.tech.db');
|
||||
}
|
||||
|
||||
// Full wipe: also clear player data and oxide data.
|
||||
if (dto.wipe_type === 'full') {
|
||||
would_delete.push(
|
||||
'player.deaths.db',
|
||||
'player.identities.db',
|
||||
'player.states.db',
|
||||
'player.tokens.db',
|
||||
'oxide/data/*',
|
||||
);
|
||||
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
|
||||
}
|
||||
|
||||
// Factor in pre_wipe_config from the profile (if set).
|
||||
let estimatedSeconds = 45;
|
||||
|
||||
if (profile) {
|
||||
const pre = profile.pre_wipe_config as Record<string, any>;
|
||||
const post = profile.post_wipe_config as Record<string, any>;
|
||||
|
||||
if (pre?.backup_before_wipe) {
|
||||
estimatedSeconds += 60;
|
||||
notes.push('Pre-wipe backup will run before deletion (+60s)');
|
||||
would_preserve.push('backups/');
|
||||
}
|
||||
|
||||
if (pre?.kick_players_before_wipe) {
|
||||
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
|
||||
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
|
||||
if (maxWarning > 0) {
|
||||
estimatedSeconds += maxWarning * 60;
|
||||
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (post?.verify_server_started) {
|
||||
estimatedSeconds += 30;
|
||||
notes.push('Post-wipe: server health check will run (+30s)');
|
||||
}
|
||||
|
||||
if (post?.rollback_on_failure) {
|
||||
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
|
||||
}
|
||||
|
||||
if (post?.max_restart_attempts) {
|
||||
const attempts = post.max_restart_attempts as number;
|
||||
if (attempts > 1) {
|
||||
estimatedSeconds += (attempts - 1) * 15;
|
||||
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notes.push('No profile selected — using default wipe behavior');
|
||||
}
|
||||
|
||||
// Account for world size in time estimate.
|
||||
// Larger worlds take longer to clear from disk (rough heuristic).
|
||||
// We don't have world_size here without querying server_config,
|
||||
// so apply a static estimate per wipe type.
|
||||
if (dto.wipe_type === 'full') {
|
||||
estimatedSeconds += 75;
|
||||
} else if (dto.wipe_type === 'blueprint') {
|
||||
estimatedSeconds += 10;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
|
||||
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
|
||||
);
|
||||
|
||||
return {
|
||||
would_delete,
|
||||
would_preserve,
|
||||
estimated_duration_seconds: estimatedSeconds,
|
||||
profile_name: profile?.profile_name ?? null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ export class NatsBridgeService implements OnModuleInit {
|
||||
this.emit(licenseId, 'server_status', data);
|
||||
});
|
||||
|
||||
this.nats.subscribe('corrosion.*.deploy.status', (data, subject) => {
|
||||
const licenseId = subject.split('.')[1];
|
||||
this.emit(licenseId, 'deploy_status', data);
|
||||
});
|
||||
|
||||
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
|
||||
const licenseId = subject.split('.')[1];
|
||||
this.emit(licenseId, 'oxide_status', data);
|
||||
});
|
||||
|
||||
this.logger.log('NATS bridge subscriptions initialized');
|
||||
}
|
||||
|
||||
|
||||
@@ -70,4 +70,21 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish a deploy command to a specific license's companion agent */
|
||||
async sendDeployCommand(licenseId: string, config: Record<string, unknown>): Promise<void> {
|
||||
await this.publish(`corrosion.${licenseId}.cmd.deploy`, {
|
||||
action: 'deploy',
|
||||
config,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish an Oxide install command to a specific license's companion agent */
|
||||
async sendOxideInstallCommand(licenseId: string): Promise<void> {
|
||||
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
|
||||
action: 'install_oxide',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/migrations/013_loot_profiles.sql
Normal file
13
backend/migrations/013_loot_profiles.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Loot profiles for BetterLoot integration
|
||||
CREATE TABLE loot_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
profile_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
loot_table JSONB NOT NULL DEFAULT '{}',
|
||||
loot_groups JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_loot_profiles_license ON loot_profiles(license_id);
|
||||
12
backend/migrations/014_teleport_configs.sql
Normal file
12
backend/migrations/014_teleport_configs.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Teleport configuration profiles for NTeleportation integration
|
||||
CREATE TABLE teleport_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
config_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
config_data JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_teleport_configs_license ON teleport_configs(license_id);
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -17,16 +18,23 @@ import (
|
||||
type Config struct {
|
||||
// NATS connection
|
||||
NATSUrl string `envconfig:"NATS_URL" required:"true"`
|
||||
NATSToken string `envconfig:"NATS_TOKEN" required:"true"`
|
||||
NATSToken string `envconfig:"NATS_TOKEN" default:""`
|
||||
|
||||
// License identification
|
||||
LicenseID string `envconfig:"LICENSE_ID" required:"true"`
|
||||
|
||||
// Game server configuration
|
||||
SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"`
|
||||
GameServerPath string `envconfig:"GAME_SERVER_PATH" required:"true"`
|
||||
GameServerPath string `envconfig:"GAME_SERVER_PATH" default:""`
|
||||
GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"`
|
||||
|
||||
// Install directory for deployment
|
||||
InstallDir string `envconfig:"INSTALL_DIR" default:""`
|
||||
|
||||
// RCON configuration
|
||||
RconPort int `envconfig:"RCON_PORT" default:"28016"`
|
||||
RconPassword string `envconfig:"RCON_PASSWORD" default:""`
|
||||
|
||||
// Optional settings
|
||||
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||
@@ -44,11 +52,22 @@ func main() {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// Set default InstallDir based on OS if not configured
|
||||
if cfg.InstallDir == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
cfg.InstallDir = `C:\RustServer`
|
||||
} else {
|
||||
cfg.InstallDir = "/opt/rustserver"
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Configuration loaded:")
|
||||
log.Printf(" NATS URL: %s", cfg.NATSUrl)
|
||||
log.Printf(" License ID: %s", cfg.LicenseID)
|
||||
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
|
||||
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
|
||||
log.Printf(" Install Dir: %s", cfg.InstallDir)
|
||||
log.Printf(" RCON Port: %d", cfg.RconPort)
|
||||
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
||||
|
||||
// Create context with signal handling for graceful shutdown
|
||||
@@ -73,6 +92,9 @@ func main() {
|
||||
GameServerPath: cfg.GameServerPath,
|
||||
GameServerArgs: cfg.GameServerArgs,
|
||||
Version: version,
|
||||
InstallDir: cfg.InstallDir,
|
||||
RconPort: cfg.RconPort,
|
||||
RconPassword: cfg.RconPassword,
|
||||
}
|
||||
|
||||
// Start daemon
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.5 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
|
||||
@@ -9,8 +9,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/oxide"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/rcon"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||
)
|
||||
|
||||
@@ -22,16 +26,22 @@ type DaemonConfig struct {
|
||||
GameServerPath string
|
||||
GameServerArgs string
|
||||
Version string
|
||||
InstallDir string
|
||||
RconPort int
|
||||
RconPassword string
|
||||
}
|
||||
|
||||
// Daemon manages the companion agent's main operations
|
||||
type Daemon struct {
|
||||
nc *nats.Conn
|
||||
cfg *DaemonConfig
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
updater *update.Updater
|
||||
subscriptions []*nats.Subscription
|
||||
nc *nats.Conn
|
||||
cfg *DaemonConfig
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
fm *filemanager.FileManager
|
||||
updater *update.Updater
|
||||
deployer *deploy.Deployer
|
||||
oxideInstaller *oxide.OxideInstaller
|
||||
subscriptions []*nats.Subscription
|
||||
}
|
||||
|
||||
// HeartbeatPayload represents the data sent in heartbeat messages
|
||||
@@ -44,23 +54,58 @@ type HeartbeatPayload struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
LastUpdate string `json:"last_update"`
|
||||
PlayerCount int `json:"player_count"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
ServerInstalled bool `json:"server_installed"`
|
||||
OxideInstalled bool `json:"oxide_installed"`
|
||||
}
|
||||
|
||||
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
||||
type gameServerAdapter struct {
|
||||
gs *process.GameServer
|
||||
cfg *DaemonConfig
|
||||
}
|
||||
|
||||
func (a *gameServerAdapter) Start() error {
|
||||
return a.gs.Start()
|
||||
}
|
||||
|
||||
func (a *gameServerAdapter) UpdatePath(path string) {
|
||||
a.cfg.GameServerPath = path
|
||||
// Recreate game server with new path
|
||||
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
|
||||
}
|
||||
|
||||
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
|
||||
type restartAdapter struct {
|
||||
gs *process.GameServer
|
||||
}
|
||||
|
||||
func (a *restartAdapter) Restart() error {
|
||||
return a.gs.Restart()
|
||||
}
|
||||
|
||||
// NewDaemon creates a new daemon instance
|
||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||
fileOps := files.NewOperations()
|
||||
fm := filemanager.New(cfg.InstallDir)
|
||||
updater := update.NewUpdater(cfg.Version)
|
||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||
restarter := &restartAdapter{gs: gameServer}
|
||||
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
|
||||
|
||||
d := &Daemon{
|
||||
nc: nc,
|
||||
cfg: cfg,
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
updater: updater,
|
||||
nc: nc,
|
||||
cfg: cfg,
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
fm: fm,
|
||||
updater: updater,
|
||||
deployer: deployer,
|
||||
oxideInstaller: oxideInst,
|
||||
}
|
||||
|
||||
return d, nil
|
||||
@@ -90,6 +135,21 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to subscribe to self-update: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to deploy commands
|
||||
if err := d.subscribeDeployCommand(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to Oxide install commands
|
||||
if err := d.subscribeOxideInstall(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||
if err := d.subscribeFileManager(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||
}
|
||||
|
||||
log.Println("All subscriptions active")
|
||||
|
||||
// Start heartbeat ticker
|
||||
@@ -118,7 +178,8 @@ func (d *Daemon) subscribeServerCommands() error {
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
var cmd struct {
|
||||
Action string `json:"action"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
||||
@@ -137,6 +198,24 @@ func (d *Daemon) subscribeServerCommands() error {
|
||||
err = d.gameServer.Stop()
|
||||
case "restart":
|
||||
err = d.gameServer.Restart()
|
||||
case "command":
|
||||
if cmd.Command == "" {
|
||||
d.respondError(msg, "invalid_command", "command field is required")
|
||||
return
|
||||
}
|
||||
result, rconErr := rcon.SendCommand(d.cfg.RconPort, d.cfg.RconPassword, cmd.Command)
|
||||
if rconErr != nil {
|
||||
log.Printf("RCON command failed: %v", rconErr)
|
||||
d.respondError(msg, "rcon_failed", rconErr.Error())
|
||||
} else {
|
||||
d.respondSuccess(msg, map[string]interface{}{
|
||||
"action": "command",
|
||||
"command": cmd.Command,
|
||||
"response": result,
|
||||
"status": "success",
|
||||
})
|
||||
}
|
||||
return
|
||||
default:
|
||||
err = fmt.Errorf("unknown action: %s", cmd.Action)
|
||||
}
|
||||
@@ -267,6 +346,101 @@ func (d *Daemon) subscribeSelfUpdate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeDeployCommand subscribes to server deployment commands
|
||||
func (d *Daemon) subscribeDeployCommand() error {
|
||||
subject := fmt.Sprintf("corrosion.%s.cmd.deploy", d.cfg.LicenseID)
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
var cmd struct {
|
||||
Action string `json:"action"`
|
||||
Config deploy.DeployConfig `json:"config"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
||||
log.Printf("Failed to parse deploy command: %v", err)
|
||||
d.respondError(msg, "invalid_command", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Received deploy command: %s", cmd.Action)
|
||||
|
||||
// Run deployment in goroutine (it's long-running)
|
||||
go func() {
|
||||
if err := d.deployer.Deploy(cmd.Config); err != nil {
|
||||
log.Printf("Deployment failed: %v", err)
|
||||
} else {
|
||||
log.Println("Deployment completed successfully")
|
||||
}
|
||||
}()
|
||||
|
||||
// Immediately acknowledge the command
|
||||
d.respondSuccess(msg, map[string]interface{}{
|
||||
"status": "accepted",
|
||||
"message": "Deployment started, progress will be published to deploy.status",
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.subscriptions = append(d.subscriptions, sub)
|
||||
log.Printf("Subscribed to: %s", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeFileManager subscribes to the VueFinder-compatible file manager
|
||||
// command subject. All operations (list, delete, rename, copy, move, mkdir,
|
||||
// mkfile, search, preview, save, upload) are handled by the filemanager package
|
||||
// which enforces the installDir jail on every path.
|
||||
func (d *Daemon) subscribeFileManager() error {
|
||||
subject := fmt.Sprintf("corrosion.%s.files.cmd", d.cfg.LicenseID)
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
d.fm.HandleNatsRequest(msg)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.subscriptions = append(d.subscriptions, sub)
|
||||
log.Printf("Subscribed to: %s", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeOxideInstall subscribes to Oxide installation commands
|
||||
func (d *Daemon) subscribeOxideInstall() error {
|
||||
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
log.Println("Received Oxide install command")
|
||||
|
||||
// Run installation in goroutine (it's long-running)
|
||||
go func() {
|
||||
if err := d.oxideInstaller.Install(); err != nil {
|
||||
log.Printf("Oxide installation failed: %v", err)
|
||||
} else {
|
||||
log.Println("Oxide installation completed successfully")
|
||||
}
|
||||
}()
|
||||
|
||||
// Immediately acknowledge the command
|
||||
d.respondSuccess(msg, map[string]interface{}{
|
||||
"status": "accepted",
|
||||
"message": "Oxide installation started, progress will be published to oxide.status",
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.subscriptions = append(d.subscriptions, sub)
|
||||
log.Printf("Subscribed to: %s", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFileOperation processes file operation requests
|
||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||
// Parse common fields
|
||||
@@ -325,17 +499,19 @@ func (d *Daemon) publishHeartbeat() {
|
||||
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
|
||||
|
||||
payload := HeartbeatPayload{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Status: "running",
|
||||
ServerStatus: status,
|
||||
UptimeSeconds: int64(uptime.Seconds()),
|
||||
DiskFreeMB: diskFree,
|
||||
CPUPercent: 0.0, // TODO: Implement CPU monitoring
|
||||
LastUpdate: "", // TODO: Track last SteamCMD update
|
||||
PlayerCount: 0, // Populated by plugin, not companion
|
||||
Version: d.cfg.Version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Status: "running",
|
||||
ServerStatus: status,
|
||||
UptimeSeconds: int64(uptime.Seconds()),
|
||||
DiskFreeMB: diskFree,
|
||||
CPUPercent: 0.0, // TODO: Implement CPU monitoring
|
||||
LastUpdate: "", // TODO: Track last SteamCMD update
|
||||
PlayerCount: 0, // Populated by plugin, not companion
|
||||
Version: d.cfg.Version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
||||
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
@@ -7,13 +7,12 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Connect establishes a connection to NATS with token authentication
|
||||
// Connect establishes a connection to NATS with optional token authentication
|
||||
// and automatic reconnection handling
|
||||
func Connect(url, token string) (*nats.Conn, error) {
|
||||
opts := []nats.Option{
|
||||
nats.Token(token),
|
||||
nats.Name("corrosion-companion"),
|
||||
nats.MaxReconnects(-1), // Unlimited reconnect attempts
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2 * time.Second),
|
||||
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
|
||||
if err != nil {
|
||||
@@ -31,6 +30,11 @@ func Connect(url, token string) (*nats.Conn, error) {
|
||||
}),
|
||||
}
|
||||
|
||||
// Only use token auth if a token is provided
|
||||
if token != "" {
|
||||
opts = append(opts, nats.Token(token))
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(url, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
|
||||
71
companion-agent/internal/deploy/config.go
Normal file
71
companion-agent/internal/deploy/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DeployConfig holds the configuration received from a NATS cmd.deploy command.
|
||||
// These fields map directly to the Rust game server settings needed for initial deployment.
|
||||
type DeployConfig struct {
|
||||
ServerName string `json:"server_name"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
WorldSize int `json:"world_size"`
|
||||
Seed int `json:"seed"`
|
||||
ServerPort int `json:"server_port"`
|
||||
RconPort int `json:"rcon_port"`
|
||||
RconPassword string `json:"rcon_password"`
|
||||
}
|
||||
|
||||
// DeployStatus represents a progress update published to NATS during deployment.
|
||||
// The frontend listens on corrosion.{license_id}.deploy.status for these messages
|
||||
// to display real-time deployment progress to the user.
|
||||
type DeployStatus struct {
|
||||
Stage string `json:"stage"`
|
||||
Progress int `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Valid deployment stages:
|
||||
// downloading_steamcmd - Downloading and extracting SteamCMD
|
||||
// installing_steamcmd - Running SteamCMD initial setup
|
||||
// downloading_rust - Downloading Rust Dedicated Server via SteamCMD
|
||||
// configuring - Generating server.cfg and identity directories
|
||||
// starting - Launching the Rust server process
|
||||
// online - Server is running and accepting connections
|
||||
// failed - Deployment failed at some stage
|
||||
|
||||
// GenerateServerCfg creates the server.cfg file for a Rust Dedicated Server.
|
||||
// It writes to {installDir}/server/server/corrosion/cfg/server.cfg, creating
|
||||
// the full directory tree if it does not already exist.
|
||||
func GenerateServerCfg(installDir string, cfg DeployConfig) error {
|
||||
cfgDir := filepath.Join(installDir, "server", "server", "corrosion", "cfg")
|
||||
|
||||
if err := os.MkdirAll(cfgDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cfg directory %s: %w", cfgDir, err)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`server.hostname "%s"
|
||||
server.maxplayers %d
|
||||
server.worldsize %d
|
||||
server.seed %d
|
||||
server.port %d
|
||||
rcon.port %d
|
||||
rcon.password "%s"
|
||||
rcon.web 1
|
||||
server.identity "corrosion"
|
||||
server.saveinterval 300
|
||||
`, cfg.ServerName, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed,
|
||||
cfg.ServerPort, cfg.RconPort, cfg.RconPassword)
|
||||
|
||||
cfgPath := filepath.Join(cfgDir, "server.cfg")
|
||||
|
||||
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write server.cfg to %s: %w", cfgPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
180
companion-agent/internal/deploy/deploy.go
Normal file
180
companion-agent/internal/deploy/deploy.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// GameServerStarter abstracts the game server process manager so the deployer
|
||||
// can set the executable path and start the server without depending on the
|
||||
// concrete process.GameServer type. The existing GameServer will implement
|
||||
// UpdatePath in a separate task.
|
||||
type GameServerStarter interface {
|
||||
Start() error
|
||||
UpdatePath(path string)
|
||||
}
|
||||
|
||||
// Deployer orchestrates one-click Rust server deployment. It downloads SteamCMD,
|
||||
// installs the Rust Dedicated Server, generates server.cfg, and starts the server
|
||||
// process — publishing progress updates to NATS at each stage so the frontend can
|
||||
// display real-time deployment status.
|
||||
type Deployer struct {
|
||||
nc *nats.Conn
|
||||
licenseID string
|
||||
installDir string
|
||||
gameServer GameServerStarter
|
||||
}
|
||||
|
||||
// NewDeployer creates a new Deployer instance.
|
||||
func NewDeployer(nc *nats.Conn, licenseID, installDir string, gs GameServerStarter) *Deployer {
|
||||
return &Deployer{
|
||||
nc: nc,
|
||||
licenseID: licenseID,
|
||||
installDir: installDir,
|
||||
gameServer: gs,
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy executes the full deployment pipeline: SteamCMD install, Rust server
|
||||
// download, config generation, and server startup. If any stage fails, a "failed"
|
||||
// status is published and the error is returned. Progress updates are published
|
||||
// to NATS at each stage transition.
|
||||
func (d *Deployer) Deploy(cfg DeployConfig) error {
|
||||
// Stage 1: SteamCMD
|
||||
log.Printf("Deploy: starting SteamCMD installation for license %s", d.licenseID)
|
||||
d.publishStatus("downloading_steamcmd", 0, "Checking for existing SteamCMD installation...")
|
||||
|
||||
steamcmdPath, err := InstallSteamCMD(d.installDir)
|
||||
if err != nil {
|
||||
d.publishStatus("failed", 0, "SteamCMD installation failed", err.Error())
|
||||
return fmt.Errorf("steamcmd install failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Deploy: SteamCMD ready at %s", steamcmdPath)
|
||||
d.publishStatus("downloading_steamcmd", 100, "SteamCMD ready")
|
||||
|
||||
// Stage 2: Download Rust Dedicated Server
|
||||
log.Printf("Deploy: downloading Rust Dedicated Server via SteamCMD")
|
||||
d.publishStatus("downloading_rust", 0, "Downloading Rust Dedicated Server via SteamCMD...")
|
||||
|
||||
if err := DownloadRustServer(steamcmdPath, d.installDir); err != nil {
|
||||
d.publishStatus("failed", 0, "Rust server download failed", err.Error())
|
||||
return fmt.Errorf("rust server download failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Deploy: Rust Dedicated Server installed")
|
||||
d.publishStatus("downloading_rust", 100, "Rust Dedicated Server installed")
|
||||
|
||||
// Stage 3: Generate server.cfg
|
||||
log.Printf("Deploy: generating server.cfg")
|
||||
d.publishStatus("configuring", 0, "Generating server.cfg...")
|
||||
|
||||
if err := GenerateServerCfg(d.installDir, cfg); err != nil {
|
||||
d.publishStatus("failed", 0, "Server configuration failed", err.Error())
|
||||
return fmt.Errorf("config generation failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Deploy: server.cfg written")
|
||||
d.publishStatus("configuring", 100, "Server configured")
|
||||
|
||||
// Stage 4: Start the server
|
||||
log.Printf("Deploy: starting Rust server")
|
||||
d.publishStatus("starting", 0, "Starting Rust server...")
|
||||
|
||||
var exePath string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
exePath = filepath.Join(d.installDir, "server", "RustDedicated.exe")
|
||||
default:
|
||||
exePath = filepath.Join(d.installDir, "server", "RustDedicated")
|
||||
}
|
||||
|
||||
d.gameServer.UpdatePath(exePath)
|
||||
|
||||
if err := d.gameServer.Start(); err != nil {
|
||||
d.publishStatus("failed", 0, "Server failed to start", err.Error())
|
||||
return fmt.Errorf("server start failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Deploy: Rust server is now running")
|
||||
d.publishStatus("online", 100, "Rust server is now running")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadRustServer runs SteamCMD to download/update the Rust Dedicated Server
|
||||
// (App ID 258550) into {installDir}/server. This function is platform-agnostic —
|
||||
// it simply executes the steamcmd binary which was installed by the platform-specific
|
||||
// InstallSteamCMD function.
|
||||
func DownloadRustServer(steamcmdPath, installDir string) error {
|
||||
serverDir := filepath.Join(installDir, "server")
|
||||
|
||||
log.Printf("Downloading Rust Dedicated Server to %s", serverDir)
|
||||
|
||||
cmd := exec.Command(steamcmdPath,
|
||||
"+login", "anonymous",
|
||||
"+force_install_dir", serverDir,
|
||||
"+app_update", "258550", "validate",
|
||||
"+quit",
|
||||
)
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("steamcmd app_update 258550 failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckServerInstalled returns true if the Rust Dedicated Server executable
|
||||
// exists at the expected path within the install directory.
|
||||
func CheckServerInstalled(installDir string) bool {
|
||||
var exePath string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
exePath = filepath.Join(installDir, "server", "RustDedicated.exe")
|
||||
default:
|
||||
exePath = filepath.Join(installDir, "server", "RustDedicated")
|
||||
}
|
||||
|
||||
_, err := os.Stat(exePath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// publishStatus publishes a DeployStatus message to the NATS subject
|
||||
// corrosion.{licenseID}.deploy.status. Publish errors are logged but do not
|
||||
// fail the deployment — losing a progress update is not fatal.
|
||||
func (d *Deployer) publishStatus(stage string, progress int, message string, errDetail ...string) {
|
||||
subject := fmt.Sprintf("corrosion.%s.deploy.status", d.licenseID)
|
||||
|
||||
status := DeployStatus{
|
||||
Stage: stage,
|
||||
Progress: progress,
|
||||
Message: message,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if len(errDetail) > 0 && errDetail[0] != "" {
|
||||
status.Error = errDetail[0]
|
||||
}
|
||||
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal deploy status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.nc.Publish(subject, data); err != nil {
|
||||
log.Printf("Failed to publish deploy status to %s: %v", subject, err)
|
||||
}
|
||||
}
|
||||
127
companion-agent/internal/deploy/deploy_linux.go
Normal file
127
companion-agent/internal/deploy/deploy_linux.go
Normal file
@@ -0,0 +1,127 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// InstallSteamCMD downloads and installs SteamCMD for Linux into the given
|
||||
// install directory. If SteamCMD is already present it returns the existing
|
||||
// path without re-downloading. The returned string is the absolute path to
|
||||
// the steamcmd.sh executable.
|
||||
func InstallSteamCMD(installDir string) (string, error) {
|
||||
steamcmdDir := filepath.Join(installDir, "steamcmd")
|
||||
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.sh")
|
||||
|
||||
// Already installed — nothing to do.
|
||||
if _, err := os.Stat(steamcmdPath); err == nil {
|
||||
log.Printf("SteamCMD already installed at %s", steamcmdPath)
|
||||
return steamcmdPath, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
|
||||
}
|
||||
|
||||
// Download the Linux tarball.
|
||||
tarball := filepath.Join(steamcmdDir, "steamcmd_linux.tar.gz")
|
||||
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz", tarball); err != nil {
|
||||
return "", fmt.Errorf("failed to download steamcmd: %w", err)
|
||||
}
|
||||
|
||||
// Extract with tar.
|
||||
cmd := exec.Command("tar", "-xzf", "steamcmd_linux.tar.gz")
|
||||
cmd.Dir = steamcmdDir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("failed to extract steamcmd: %w — output: %s", err, string(out))
|
||||
}
|
||||
|
||||
// Ensure the script is executable.
|
||||
if err := os.Chmod(steamcmdPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to chmod steamcmd.sh: %w", err)
|
||||
}
|
||||
|
||||
// Verify the installation by running +quit (triggers first-time setup).
|
||||
verify := exec.Command(steamcmdPath, "+quit")
|
||||
verify.Dir = steamcmdDir
|
||||
if out, err := verify.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("steamcmd verification failed: %w — output: %s", err, string(out))
|
||||
}
|
||||
|
||||
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
|
||||
return steamcmdPath, nil
|
||||
}
|
||||
|
||||
// RegisterService creates a systemd unit file for the Rust Dedicated Server
|
||||
// and enables it. If the caller does not have root access, the unit file is
|
||||
// written into installDir as a fallback so the user can install it manually.
|
||||
func RegisterService(installDir string, cfg DeployConfig) error {
|
||||
serverPath := filepath.Join(installDir, "server", "RustDedicated")
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Rust Dedicated Server (Corrosion Managed)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s/server
|
||||
ExecStart=%s -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, installDir, serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
|
||||
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
|
||||
|
||||
systemdPath := "/etc/systemd/system/rustserver.service"
|
||||
if err := os.WriteFile(systemdPath, []byte(unit), 0644); err != nil {
|
||||
// Fallback — write into installDir so the user can place it manually.
|
||||
fallback := filepath.Join(installDir, "rustserver.service")
|
||||
log.Printf("WARNING: cannot write to %s (%v), falling back to %s", systemdPath, err, fallback)
|
||||
if writeErr := os.WriteFile(fallback, []byte(unit), 0644); writeErr != nil {
|
||||
return fmt.Errorf("failed to write service file to fallback %s: %w", fallback, writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort daemon-reload and enable — ignore errors (systemctl may not
|
||||
// exist or the user may lack privileges).
|
||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||
_ = exec.Command("systemctl", "enable", "rustserver").Run()
|
||||
|
||||
log.Println("Systemd service registered for rustserver")
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile fetches url and writes the response body to dest on disk.
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s failed: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", dest, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write to %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
145
companion-agent/internal/deploy/deploy_windows.go
Normal file
145
companion-agent/internal/deploy/deploy_windows.go
Normal file
@@ -0,0 +1,145 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// InstallSteamCMD downloads and installs SteamCMD for Windows into the given
|
||||
// install directory. If SteamCMD is already present it returns the existing
|
||||
// path without re-downloading. The returned string is the absolute path to
|
||||
// steamcmd.exe.
|
||||
func InstallSteamCMD(installDir string) (string, error) {
|
||||
steamcmdDir := filepath.Join(installDir, "steamcmd")
|
||||
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.exe")
|
||||
|
||||
// Already installed — nothing to do.
|
||||
if _, err := os.Stat(steamcmdPath); err == nil {
|
||||
log.Printf("SteamCMD already installed at %s", steamcmdPath)
|
||||
return steamcmdPath, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
|
||||
}
|
||||
|
||||
// Download the Windows zip.
|
||||
zipPath := filepath.Join(steamcmdDir, "steamcmd.zip")
|
||||
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", zipPath); err != nil {
|
||||
return "", fmt.Errorf("failed to download steamcmd: %w", err)
|
||||
}
|
||||
|
||||
// Extract the zip into steamcmdDir.
|
||||
if err := extractZip(zipPath, steamcmdDir); err != nil {
|
||||
return "", fmt.Errorf("failed to extract steamcmd.zip: %w", err)
|
||||
}
|
||||
|
||||
// Verify the exe landed where expected.
|
||||
if _, err := os.Stat(steamcmdPath); err != nil {
|
||||
return "", fmt.Errorf("steamcmd.exe not found after extraction: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
|
||||
return steamcmdPath, nil
|
||||
}
|
||||
|
||||
// RegisterService creates a Windows service for the Rust Dedicated Server
|
||||
// using sc.exe. If the caller does not have administrator privileges the
|
||||
// command will fail silently with a warning log.
|
||||
func RegisterService(installDir string, cfg DeployConfig) error {
|
||||
serverPath := filepath.Join(installDir, "server", "RustDedicated.exe")
|
||||
|
||||
binPath := fmt.Sprintf(`"%s" -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d`,
|
||||
serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
|
||||
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
|
||||
|
||||
cmd := exec.Command("sc.exe", "create", "RustServer", "binPath=", binPath, "start=", "auto")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("WARNING: sc.exe create failed (may require admin): %v — output: %s", err, string(out))
|
||||
} else {
|
||||
log.Println("Windows service RustServer registered successfully")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile fetches url and writes the response body to dest on disk.
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s failed: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", dest, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write to %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZip extracts all files from a zip archive into destDir, preserving
|
||||
// the directory structure from the archive.
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip %s: %w", zipPath, err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
target := filepath.Join(destDir, f.Name)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", target, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure the parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for %s: %w", target, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(target)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("failed to create file %s: %w", target, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, rc); err != nil {
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
735
companion-agent/internal/filemanager/filemanager.go
Normal file
735
companion-agent/internal/filemanager/filemanager.go
Normal file
@@ -0,0 +1,735 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxReadSize is the maximum file size allowed for GetContent (5 MB).
|
||||
// This guards against accidentally reading large binary files into memory.
|
||||
maxReadSize = 5 * 1024 * 1024
|
||||
|
||||
// maxSearchResults caps the number of results returned by Search.
|
||||
maxSearchResults = 100
|
||||
|
||||
// storageName is the VueFinder storage identifier used in all path strings.
|
||||
storageName = "server"
|
||||
|
||||
// adapterName is the VueFinder adapter field included in every list/search response.
|
||||
adapterName = "local"
|
||||
)
|
||||
|
||||
// FileManager handles sandboxed filesystem operations for the game server
|
||||
// install directory. All operations are confined to installDir — any path
|
||||
// that resolves outside that boundary is rejected with an error.
|
||||
type FileManager struct {
|
||||
// installDir is the absolute, symlink-resolved root of the jail.
|
||||
// It is set once at construction and never changes.
|
||||
installDir string
|
||||
}
|
||||
|
||||
// New creates a FileManager jailed to installDir. The directory is cleaned and
|
||||
// made absolute but NOT required to exist at construction time — the daemon may
|
||||
// start before the install completes.
|
||||
func New(installDir string) *FileManager {
|
||||
// Clean and make absolute so comparisons are deterministic even if the
|
||||
// caller passed a relative or un-normalised path.
|
||||
abs, err := filepath.Abs(filepath.Clean(installDir))
|
||||
if err != nil {
|
||||
// Abs only fails on systems where os.Getwd() fails; fall back to raw value.
|
||||
log.Printf("filemanager: warning: could not make installDir absolute (%v), using raw value", err)
|
||||
abs = filepath.Clean(installDir)
|
||||
}
|
||||
return &FileManager{installDir: abs}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — one method per VueFinder operation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List returns the contents of the directory identified by storagePath.
|
||||
func (fm *FileManager) List(storagePath string) (*ListResponse, error) {
|
||||
abs, rel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read directory: %w", err)
|
||||
}
|
||||
|
||||
files, err := buildFileItems(entries, abs, rel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(rel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete removes every item listed in items from within storagePath's directory.
|
||||
// After deletion it returns a fresh listing of the parent directory.
|
||||
func (fm *FileManager) Delete(storagePath string, items []string) (*ListResponse, error) {
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
if err := os.RemoveAll(itemAbs); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete %q: %w", item, err)
|
||||
}
|
||||
log.Printf("filemanager: deleted %s", itemAbs)
|
||||
}
|
||||
|
||||
// Return a fresh listing of the parent so the frontend can update immediately.
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after delete: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Rename renames item inside storagePath to newName.
|
||||
// newName must be a bare filename, not a path — slashes are rejected.
|
||||
func (fm *FileManager) Rename(storagePath string, item string, newName string) (*ListResponse, error) {
|
||||
if strings.ContainsAny(newName, "/\\") {
|
||||
return nil, fmt.Errorf("new name must not contain path separators")
|
||||
}
|
||||
if newName == "" || newName == "." || newName == ".." {
|
||||
return nil, fmt.Errorf("invalid new name %q", newName)
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
|
||||
destAbs := filepath.Join(parentAbs, newName)
|
||||
// Verify the destination is also inside the jail before committing.
|
||||
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||
return nil, fmt.Errorf("destination escapes jail: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(itemAbs, destAbs); err != nil {
|
||||
return nil, fmt.Errorf("rename failed: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: renamed %s -> %s", itemAbs, destAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after rename: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFolder creates a new directory named name inside storagePath.
|
||||
func (fm *FileManager) CreateFolder(storagePath string, name string) (*ListResponse, error) {
|
||||
if err := validateBareName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newDirAbs := filepath.Join(parentAbs, name)
|
||||
if err := fm.checkWithinJail(newDirAbs); err != nil {
|
||||
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(newDirAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: created directory %s", newDirAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after mkdir: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile creates an empty file named name inside storagePath.
|
||||
func (fm *FileManager) CreateFile(storagePath string, name string) (*ListResponse, error) {
|
||||
if err := validateBareName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newFileAbs := filepath.Join(parentAbs, name)
|
||||
if err := fm.checkWithinJail(newFileAbs); err != nil {
|
||||
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(newFileAbs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
log.Printf("filemanager: created file %s", newFileAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after mkfile: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetContent reads and returns the UTF-8 content of the file at storagePath.
|
||||
// Reading is capped at maxReadSize (5 MB) to avoid loading large binaries.
|
||||
func (fm *FileManager) GetContent(storagePath string) (string, error) {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot stat file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
if info.Size() > maxReadSize {
|
||||
return "", fmt.Errorf("file size %d bytes exceeds read limit of %d bytes", info.Size(), maxReadSize)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read file: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SaveContent overwrites the file at storagePath with content.
|
||||
func (fm *FileManager) SaveContent(storagePath string, content string) error {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
|
||||
return fmt.Errorf("cannot create parent directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(abs, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("cannot write file: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: saved %d bytes to %s", len(content), abs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search walks the directory at storagePath and returns files whose names
|
||||
// contain filter (case-insensitive). Results are capped at maxSearchResults.
|
||||
func (fm *FileManager) Search(storagePath string, filter string) (*SearchResponse, error) {
|
||||
rootAbs, rootRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lowerFilter := strings.ToLower(filter)
|
||||
var results []FileItem
|
||||
|
||||
err = filepath.WalkDir(rootAbs, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
// Skip entries that produce errors (e.g. permission denied) rather than aborting.
|
||||
log.Printf("filemanager: search: skipping %s: %v", path, walkErr)
|
||||
return nil
|
||||
}
|
||||
if path == rootAbs {
|
||||
// Skip the root itself.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Security: verify every path we touch is within the jail.
|
||||
if err := fm.checkWithinJail(path); err != nil {
|
||||
log.Printf("filemanager: search: path %s escaped jail, skipping", path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(d.Name()), lowerFilter) {
|
||||
rel, relErr := filepath.Rel(fm.installDir, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
item, buildErr := buildFileItem(d, path, rel)
|
||||
if buildErr != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, item)
|
||||
if len(results) >= maxSearchResults {
|
||||
return io.EOF // Signal early exit; WalkDir treats this as a stop, not an error.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// WalkDir returns io.EOF only if we injected it; suppress it here.
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("search walk failed: %w", err)
|
||||
}
|
||||
|
||||
_ = rootRel // rootRel used for Dirname below.
|
||||
return &SearchResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(rootRel),
|
||||
Files: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Move moves every item in items into destination.
|
||||
// After the move it returns a fresh listing of destination.
|
||||
func (fm *FileManager) Move(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||
}
|
||||
|
||||
// Ensure destination directory exists.
|
||||
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("move target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.Rename(itemAbs, targetAbs); err != nil {
|
||||
// os.Rename fails across filesystems; fall back to copy+delete.
|
||||
if err2 := copyRecursive(itemAbs, targetAbs); err2 != nil {
|
||||
return nil, fmt.Errorf("failed to move %q: %w", item, err2)
|
||||
}
|
||||
if err2 := os.RemoveAll(itemAbs); err2 != nil {
|
||||
return nil, fmt.Errorf("moved %q but failed to remove source: %w", item, err2)
|
||||
}
|
||||
}
|
||||
log.Printf("filemanager: moved %s -> %s", itemAbs, targetAbs)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(destAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read destination after move: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(destRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Copy copies every item in items into destination.
|
||||
// After the copy it returns a fresh listing of destination.
|
||||
func (fm *FileManager) Copy(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("copy target escapes jail: %w", err)
|
||||
}
|
||||
if err := copyRecursive(itemAbs, targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("failed to copy %q: %w", item, err)
|
||||
}
|
||||
log.Printf("filemanager: copied %s -> %s", itemAbs, targetAbs)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(destAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read destination after copy: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(destRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload decodes the base64-encoded content and writes it as filename inside storagePath.
|
||||
// After the write it returns a fresh listing of the parent directory.
|
||||
func (fm *FileManager) Upload(storagePath string, filename string, content []byte) (*ListResponse, error) {
|
||||
if err := validateBareName(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
destAbs := filepath.Join(parentAbs, filename)
|
||||
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||
return nil, fmt.Errorf("upload target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(parentAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create upload directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(destAbs, content, 0644); err != nil {
|
||||
return nil, fmt.Errorf("cannot write uploaded file: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: uploaded %d bytes to %s", len(content), destAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after upload: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path parsing and security helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ParsePath converts a VueFinder storage path ("server://relative/path") into
|
||||
// the absolute filesystem path within the jail.
|
||||
//
|
||||
// Rules:
|
||||
// - Must have the form "server://<relative>" where relative may be empty.
|
||||
// - The storage identifier must be "server" — anything else is rejected.
|
||||
// - The resulting absolute path must be within installDir.
|
||||
func (fm *FileManager) ParsePath(storagePath string) (string, error) {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
return abs, err
|
||||
}
|
||||
|
||||
// parseAndResolve parses a storage path and resolves it to an absolute path.
|
||||
// Returns (absolutePath, relativePath, error). relativePath is relative to
|
||||
// installDir and uses the OS path separator.
|
||||
func (fm *FileManager) parseAndResolve(storagePath string) (absPath string, relPath string, err error) {
|
||||
const sep = "://"
|
||||
|
||||
idx := strings.Index(storagePath, sep)
|
||||
if idx < 0 {
|
||||
// Tolerate a bare relative path for internal calls, but flag it.
|
||||
return "", "", fmt.Errorf("invalid storage path %q: missing storage prefix (expected %q://...)", storagePath, storageName)
|
||||
}
|
||||
|
||||
storage := storagePath[:idx]
|
||||
if storage != storageName {
|
||||
return "", "", fmt.Errorf("unknown storage %q: only %q is supported", storage, storageName)
|
||||
}
|
||||
|
||||
relative := storagePath[idx+len(sep):]
|
||||
|
||||
return fm.resolvePath(relative)
|
||||
}
|
||||
|
||||
// resolvePath resolves a relative path (which may be empty, representing the
|
||||
// root) to an absolute path inside the jail. It runs filepath.EvalSymlinks on
|
||||
// the resolved path and then re-verifies the prefix to prevent symlink escapes.
|
||||
// Parent-traversal via ".." is neutralised by filepath.Clean before joining.
|
||||
func (fm *FileManager) resolvePath(relativePath string) (absPath string, relPath string, err error) {
|
||||
// filepath.Clean neutralises ".." sequences before we ever join.
|
||||
cleaned := filepath.Clean(relativePath)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
|
||||
var absolute string
|
||||
if cleaned == "" {
|
||||
absolute = fm.installDir
|
||||
} else {
|
||||
absolute = filepath.Join(fm.installDir, cleaned)
|
||||
}
|
||||
|
||||
// First prefix check — fast path before hitting the filesystem.
|
||||
if !strings.HasPrefix(absolute, fm.installDir+string(filepath.Separator)) && absolute != fm.installDir {
|
||||
return "", "", fmt.Errorf("path %q escapes install directory", relativePath)
|
||||
}
|
||||
|
||||
// Resolve symlinks so we can do an authoritative prefix check.
|
||||
resolved, symlinkErr := filepath.EvalSymlinks(absolute)
|
||||
if symlinkErr != nil {
|
||||
// EvalSymlinks fails if the path does not exist yet (e.g. a new file).
|
||||
// In that case fall back to the unresolved absolute path; the subsequent
|
||||
// prefix check is still valid because we already cleaned ".." away.
|
||||
resolved = absolute
|
||||
}
|
||||
|
||||
// Authoritative prefix check on the symlink-resolved path.
|
||||
if !strings.HasPrefix(resolved, fm.installDir+string(filepath.Separator)) && resolved != fm.installDir {
|
||||
return "", "", fmt.Errorf("path %q resolves outside install directory (possible symlink escape)", relativePath)
|
||||
}
|
||||
|
||||
// Compute relative portion for use in VueFinder response paths.
|
||||
rel, relErr := filepath.Rel(fm.installDir, resolved)
|
||||
if relErr != nil {
|
||||
rel = cleaned
|
||||
}
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
|
||||
return resolved, rel, nil
|
||||
}
|
||||
|
||||
// checkWithinJail verifies that an absolute path (already joined but not yet
|
||||
// symlink-resolved) stays within the jail. Used for destination paths that may
|
||||
// not exist yet.
|
||||
func (fm *FileManager) checkWithinJail(absPath string) error {
|
||||
clean := filepath.Clean(absPath)
|
||||
if !strings.HasPrefix(clean, fm.installDir+string(filepath.Separator)) && clean != fm.installDir {
|
||||
return fmt.Errorf("path %q is outside the install directory", absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VueFinder path formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// toStoragePath converts a relative path (relative to installDir) to the
|
||||
// "server://..." format expected by VueFinder. An empty relative path maps to
|
||||
// "server://".
|
||||
func toStoragePath(relPath string) string {
|
||||
// Normalise OS separators to forward slashes for the JSON response.
|
||||
fwd := filepath.ToSlash(relPath)
|
||||
return storageName + "://" + fwd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileItem construction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildFileItems builds the slice of FileItem values for a directory listing.
|
||||
func buildFileItems(entries []fs.DirEntry, dirAbs string, dirRel string, installDir string) ([]FileItem, error) {
|
||||
items := make([]FileItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirAbs, entry.Name())
|
||||
entryRel := filepath.Join(dirRel, entry.Name())
|
||||
if dirRel == "" {
|
||||
entryRel = entry.Name()
|
||||
}
|
||||
|
||||
item, err := buildFileItem(entry, entryPath, entryRel)
|
||||
if err != nil {
|
||||
log.Printf("filemanager: warning: skipping %s: %v", entryPath, err)
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// buildFileItem builds a single FileItem from a DirEntry.
|
||||
// entryAbs is the absolute path; entryRel is relative to installDir.
|
||||
func buildFileItem(entry fs.DirEntry, entryAbs string, entryRel string) (FileItem, error) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return FileItem{}, fmt.Errorf("cannot stat %s: %w", entryAbs, err)
|
||||
}
|
||||
|
||||
itemType := "file"
|
||||
ext := ""
|
||||
size := info.Size()
|
||||
|
||||
if entry.IsDir() {
|
||||
itemType = "dir"
|
||||
size = 0
|
||||
} else {
|
||||
raw := filepath.Ext(entry.Name())
|
||||
if len(raw) > 0 {
|
||||
ext = raw[1:] // strip the leading dot
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise to forward slashes for the JSON path field.
|
||||
fwdRel := filepath.ToSlash(entryRel)
|
||||
|
||||
return FileItem{
|
||||
Type: itemType,
|
||||
Path: storageName + "://" + fwdRel,
|
||||
Basename: entry.Name(),
|
||||
Extension: ext,
|
||||
Storage: storageName,
|
||||
FileSize: size,
|
||||
LastModified: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive copy helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// copyRecursive copies src to dst. If src is a directory it is copied
|
||||
// recursively. dst must not exist.
|
||||
func copyRecursive(src, dst string) error {
|
||||
srcInfo, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat source: %w", err)
|
||||
}
|
||||
|
||||
if srcInfo.IsDir() {
|
||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||
return fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read source directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular file copy.
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open source file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create destination file: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return fmt.Errorf("copy failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// validateBareName rejects names that are empty, are "." or "..", or contain
|
||||
// path separators. This is used to validate user-supplied filenames for create,
|
||||
// rename, and upload operations.
|
||||
func validateBareName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name must not be empty")
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("name %q is not allowed", name)
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("name %q must not contain path separators", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeBase64 is a convenience wrapper used by the handler when the upload
|
||||
// content arrives as a base64 string.
|
||||
func DecodeBase64(encoded string) ([]byte, error) {
|
||||
// Accept both standard and URL-safe base64, with or without padding.
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try URL-safe encoding as a fallback.
|
||||
decoded, err = base64.URLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try raw (no padding) variants.
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot base64-decode upload content: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// suppress unused import warning — time is used in buildFileItem via ModTime.Format
|
||||
var _ = time.Now
|
||||
209
companion-agent/internal/filemanager/handler.go
Normal file
209
companion-agent/internal/filemanager/handler.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// HandleNatsRequest is the NATS message handler for the file manager command
|
||||
// subject (corrosion.{license_id}.files.cmd). It deserialises the request,
|
||||
// routes to the correct FileManager operation, and calls msg.Respond with a
|
||||
// NatsResponse JSON payload — either success with data or a structured error.
|
||||
func (fm *FileManager) HandleNatsRequest(msg *nats.Msg) {
|
||||
var req NatsRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
log.Printf("filemanager: invalid NATS request payload: %v", err)
|
||||
respondError(msg, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("filemanager: handling %s path=%q", req.Func, req.Path)
|
||||
|
||||
switch req.Func {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Directory listing
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncList:
|
||||
result, err := fm.List(req.Path)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delete
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncDelete:
|
||||
result, err := fm.Delete(req.Path, req.Items)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rename — req.Name holds the new basename
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncRename:
|
||||
if len(req.Items) == 0 {
|
||||
respondError(msg, "rename requires at least one item")
|
||||
return
|
||||
}
|
||||
result, err := fm.Rename(req.Path, req.Items[0], req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Copy
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCopy:
|
||||
if req.Destination == "" {
|
||||
respondError(msg, "copy requires a destination path")
|
||||
return
|
||||
}
|
||||
result, err := fm.Copy(req.Path, req.Items, req.Destination)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncMove:
|
||||
if req.Destination == "" {
|
||||
respondError(msg, "move requires a destination path")
|
||||
return
|
||||
}
|
||||
result, err := fm.Move(req.Path, req.Items, req.Destination)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Create directory
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCreateFolder:
|
||||
if req.Name == "" {
|
||||
respondError(msg, "mkdir requires a folder name")
|
||||
return
|
||||
}
|
||||
result, err := fm.CreateFolder(req.Path, req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Create empty file
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCreateFile:
|
||||
if req.Name == "" {
|
||||
respondError(msg, "mkfile requires a file name")
|
||||
return
|
||||
}
|
||||
result, err := fm.CreateFile(req.Path, req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncSearch:
|
||||
result, err := fm.Search(req.Path, req.Filter)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Preview / read file content (VueFinder uses "fm_preview" for text files)
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncPreview:
|
||||
content, err := fm.GetContent(req.Path)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, map[string]string{"content": content})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Save file content
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncSave:
|
||||
if err := fm.SaveContent(req.Path, req.Content); err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, map[string]string{"status": "saved"})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Upload — content arrives as a base64-encoded string
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncUpload:
|
||||
if req.Filename == "" {
|
||||
respondError(msg, "upload requires a filename")
|
||||
return
|
||||
}
|
||||
data, err := DecodeBase64(req.Content)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
result, err := fm.Upload(req.Path, req.Filename, data)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Unknown function
|
||||
// -----------------------------------------------------------------------
|
||||
default:
|
||||
log.Printf("filemanager: unknown function %q", req.Func)
|
||||
respondError(msg, "unknown function: "+req.Func)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// respondJSON sends a successful NatsResponse wrapping data.
|
||||
func respondJSON(msg *nats.Msg, data interface{}) {
|
||||
resp := NatsResponse{Success: true, Data: data}
|
||||
bytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Printf("filemanager: failed to marshal success response: %v", err)
|
||||
respondError(msg, "internal: failed to marshal response")
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(bytes); err != nil {
|
||||
log.Printf("filemanager: failed to send response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// respondError sends a failed NatsResponse with the given error message.
|
||||
func respondError(msg *nats.Msg, errMsg string) {
|
||||
resp := NatsResponse{Success: false, Error: errMsg}
|
||||
bytes, _ := json.Marshal(resp)
|
||||
if err := msg.Respond(bytes); err != nil {
|
||||
log.Printf("filemanager: failed to send error response: %v", err)
|
||||
}
|
||||
}
|
||||
62
companion-agent/internal/filemanager/types.go
Normal file
62
companion-agent/internal/filemanager/types.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package filemanager
|
||||
|
||||
// FileItem represents a file or directory in VueFinder format.
|
||||
type FileItem struct {
|
||||
Type string `json:"type"` // "dir" or "file"
|
||||
Path string `json:"path"` // "server://relative/path"
|
||||
Basename string `json:"basename"` // filename.ext
|
||||
Extension string `json:"extension"` // "ext" or "" for dirs
|
||||
Storage string `json:"storage"` // always "server"
|
||||
FileSize int64 `json:"file_size"` // bytes, 0 for dirs
|
||||
LastModified string `json:"last_modified"` // "2006-01-02 15:04:05"
|
||||
}
|
||||
|
||||
// ListResponse is the VueFinder index/directory response.
|
||||
type ListResponse struct {
|
||||
Adapter string `json:"adapter"` // always "local"
|
||||
Storages []string `json:"storages"` // ["server"]
|
||||
Dirname string `json:"dirname"` // "server://current/path"
|
||||
Files []FileItem `json:"files"` // directory contents
|
||||
}
|
||||
|
||||
// SearchResponse is the VueFinder search response.
|
||||
type SearchResponse struct {
|
||||
Adapter string `json:"adapter"`
|
||||
Storages []string `json:"storages"`
|
||||
Dirname string `json:"dirname"`
|
||||
Files []FileItem `json:"files"`
|
||||
}
|
||||
|
||||
// NatsRequest is the NATS request payload sent by the backend for file manager operations.
|
||||
type NatsRequest struct {
|
||||
Func string `json:"func"` // "fm_list", "fm_delete", etc.
|
||||
Path string `json:"path"` // VueFinder storage path, e.g. "server://cfg"
|
||||
Items []string `json:"items,omitempty"` // items to delete/move/copy (relative storage paths)
|
||||
Name string `json:"name,omitempty"` // new name for rename/create
|
||||
Destination string `json:"destination,omitempty"` // destination storage path for move/copy
|
||||
Filter string `json:"filter,omitempty"` // search filter string
|
||||
Content string `json:"content,omitempty"` // file content (save) or base64-encoded data (upload)
|
||||
Filename string `json:"filename,omitempty"` // original filename for upload
|
||||
}
|
||||
|
||||
// NatsResponse wraps every response sent back to the backend.
|
||||
type NatsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Function name constants matching what the backend sends.
|
||||
const (
|
||||
FuncList = "fm_list"
|
||||
FuncDelete = "fm_delete"
|
||||
FuncRename = "fm_rename"
|
||||
FuncCopy = "fm_copy"
|
||||
FuncMove = "fm_move"
|
||||
FuncCreateFolder = "fm_mkdir"
|
||||
FuncCreateFile = "fm_mkfile"
|
||||
FuncSearch = "fm_search"
|
||||
FuncPreview = "fm_preview"
|
||||
FuncSave = "fm_save"
|
||||
FuncUpload = "fm_upload"
|
||||
)
|
||||
250
companion-agent/internal/oxide/installer.go
Normal file
250
companion-agent/internal/oxide/installer.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package oxide
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// GameServerRestarter abstracts the game server process manager so the installer
|
||||
// can restart the server after extracting Oxide files.
|
||||
type GameServerRestarter interface {
|
||||
Restart() error
|
||||
}
|
||||
|
||||
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
|
||||
type OxideInstaller struct {
|
||||
nc *nats.Conn
|
||||
licenseID string
|
||||
installDir string
|
||||
gameServer GameServerRestarter
|
||||
}
|
||||
|
||||
// NewOxideInstaller creates a new OxideInstaller instance.
|
||||
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
|
||||
return &OxideInstaller{
|
||||
nc: nc,
|
||||
licenseID: licenseID,
|
||||
installDir: installDir,
|
||||
gameServer: gs,
|
||||
}
|
||||
}
|
||||
|
||||
// githubRelease represents the relevant fields from the GitHub Releases API response.
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// Install performs the full Oxide installation pipeline:
|
||||
// 1. Fetch latest release info from GitHub
|
||||
// 2. Download the zip
|
||||
// 3. Extract over {installDir}/server/
|
||||
// 4. Restart the game server
|
||||
func (o *OxideInstaller) Install() error {
|
||||
// Stage 1: Fetch latest release
|
||||
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
|
||||
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
|
||||
|
||||
release, err := o.fetchLatestRelease()
|
||||
if err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
|
||||
return fmt.Errorf("fetch release failed: %w", err)
|
||||
}
|
||||
|
||||
if len(release.Assets) == 0 {
|
||||
err := fmt.Errorf("no assets found in release %s", release.TagName)
|
||||
o.publishStatus("failed", 0, "No download assets in release", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
downloadURL := release.Assets[0].BrowserDownloadURL
|
||||
version := release.TagName
|
||||
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
|
||||
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
|
||||
|
||||
// Stage 2: Download zip
|
||||
log.Printf("Oxide: downloading %s", downloadURL)
|
||||
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
|
||||
|
||||
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
|
||||
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
log.Printf("Oxide: download complete")
|
||||
o.publishStatus("downloading", 100, "Download complete")
|
||||
|
||||
// Stage 3: Extract over server directory
|
||||
serverDir := filepath.Join(o.installDir, "server")
|
||||
log.Printf("Oxide: extracting to %s", serverDir)
|
||||
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
|
||||
|
||||
if err := o.extractZip(tmpPath, serverDir); err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
|
||||
return fmt.Errorf("extract failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Oxide: extraction complete")
|
||||
o.publishStatus("installing", 100, "Oxide files extracted")
|
||||
|
||||
// Stage 4: Restart server
|
||||
log.Printf("Oxide: restarting server")
|
||||
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
|
||||
|
||||
if err := o.gameServer.Restart(); err != nil {
|
||||
o.publishStatus("failed", 0, "Server restart failed", err.Error())
|
||||
return fmt.Errorf("server restart failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Oxide: server restarted, installation complete")
|
||||
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
|
||||
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GitHub API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path.
|
||||
func (o *OxideInstaller) downloadFile(url, destPath string) error {
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP GET failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write download: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZip extracts a zip file to a destination directory, overwriting existing files.
|
||||
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
|
||||
// and create the oxide/ folder structure.
|
||||
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
targetPath := filepath.Join(destDir, f.Name)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
|
||||
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
|
||||
// but do not fail the installation — losing a progress update is not fatal.
|
||||
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
|
||||
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
|
||||
|
||||
status := OxideStatus{
|
||||
Stage: stage,
|
||||
Progress: progress,
|
||||
Message: message,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if len(errDetail) > 0 && errDetail[0] != "" {
|
||||
status.Error = errDetail[0]
|
||||
}
|
||||
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal oxide status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := o.nc.Publish(subject, data); err != nil {
|
||||
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
|
||||
}
|
||||
}
|
||||
31
companion-agent/internal/oxide/status.go
Normal file
31
companion-agent/internal/oxide/status.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package oxide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// OxideStatus represents a progress update published to NATS during Oxide installation.
|
||||
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
|
||||
type OxideStatus struct {
|
||||
Stage string `json:"stage"`
|
||||
Progress int `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Valid installation stages:
|
||||
// fetching_release - Querying GitHub API for latest Oxide.Rust release
|
||||
// downloading - Downloading the Oxide zip file
|
||||
// installing - Extracting zip over server directory
|
||||
// restarting - Restarting the game server to load Oxide
|
||||
// complete - Oxide installation finished successfully
|
||||
// failed - Installation failed at some stage
|
||||
|
||||
// CheckOxideInstalled returns true if the oxide/ directory exists in the
|
||||
// server installation directory, indicating that Oxide/uMod has been installed.
|
||||
func CheckOxideInstalled(installDir string) bool {
|
||||
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
|
||||
return err == nil
|
||||
}
|
||||
80
companion-agent/internal/rcon/client.go
Normal file
80
companion-agent/internal/rcon/client.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// RconRequest is the JSON payload sent to Rust's WebRCON.
|
||||
type RconRequest struct {
|
||||
Identifier int `json:"Identifier"`
|
||||
Message string `json:"Message"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// RconResponse is the JSON payload received from Rust's WebRCON.
|
||||
type RconResponse struct {
|
||||
Identifier int `json:"Identifier"`
|
||||
Message string `json:"Message"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
// SendCommand opens a WebSocket to the Rust server's RCON port, sends
|
||||
// a single command, reads the response, and closes the connection.
|
||||
func SendCommand(port int, password string, command string) (string, error) {
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Path: fmt.Sprintf("/%s", password),
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rcon dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Set read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
req := RconRequest{
|
||||
Identifier: 1,
|
||||
Message: command,
|
||||
Name: "Corrosion",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rcon marshal failed: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return "", fmt.Errorf("rcon write failed: %w", err)
|
||||
}
|
||||
|
||||
// Read response — may get multiple messages (Generic, Warning, etc.)
|
||||
// We want the first response with our Identifier.
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rcon read failed: %w", err)
|
||||
}
|
||||
|
||||
var resp RconResponse
|
||||
if err := json.Unmarshal(message, &resp); err != nil {
|
||||
continue // skip unparseable messages
|
||||
}
|
||||
|
||||
if resp.Identifier == req.Identifier {
|
||||
return resp.Message, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
DATABASE_MAX_CONNECTIONS: "20"
|
||||
NATS_URL: nats://nats:4222
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ACCESS_EXPIRY_SECONDS: "900"
|
||||
JWT_ACCESS_EXPIRY_SECONDS: "14400"
|
||||
JWT_REFRESH_EXPIRY_SECONDS: "604800"
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
|
||||
587
frontend/package-lock.json
generated
587
frontend/package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
"vue-router": "^5.0.2",
|
||||
"vuefinder": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -530,6 +531,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -575,6 +601,71 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/i18n": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/i18n/-/i18n-1.2.2.tgz",
|
||||
"integrity": "sha512-5LLxl95+ZI46MrM/Kn7YjORKsD7+Xy2tgjZ7/oDT/BGPEiaBM9lK89/afeK+BqaQL0Xd9Xaa5MPuuVSyWAo+/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/persistent": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/persistent/-/persistent-1.3.3.tgz",
|
||||
"integrity": "sha512-+b4I8xrmjhKE3hQ9V7/b4Xa+MBMkM2P4Ulv33zFEF/+2Hucsb24vTjYiWR8R97y8YdRptmRKlL5Qwy0q1Jj5nQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/vue": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/vue/-/vue-1.0.1.tgz",
|
||||
"integrity": "sha512-0VwubMTMvEdWQhVN4BAvDZ+vHQH3O1G9BaOfgrjfF4erqBsWScoK/zyaBeRfFjptNOb25947EFPHBZwEf9JcMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "buymeacoffee",
|
||||
"url": "https://buymeacoffee.com/euaaaio"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nanostores/logger": "^0.4.0 || ^1.0.0",
|
||||
"@vue/devtools-api": ">=7.6.2",
|
||||
"nanostores": "^0.11.3 || ^1.0.0",
|
||||
"vue": ">=3.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nanostores/logger": {
|
||||
"optional": true
|
||||
},
|
||||
"@vue/devtools-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -1204,6 +1295,95 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/match-sorter-utils": {
|
||||
"version": "8.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
|
||||
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"remove-accents": "0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query": {
|
||||
"version": "5.92.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.92.9.tgz",
|
||||
"integrity": "sha512-jjAZcqKveyX0C4w/6zUqbnqk/XzuxNWaFsWjGTJWULVFizUNeLGME2gf9vVSDclIyiBhR13oZJPPs6fJgfpIJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/query-core": "5.90.20",
|
||||
"@vue/devtools-api": "^6.6.3",
|
||||
"vue-demi": "^0.14.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.1.2",
|
||||
"vue": "^2.6.0 || ^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/vue-query/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/@transloadit/prettier-bytes": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz",
|
||||
"integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1221,6 +1401,166 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
|
||||
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uppy/companion-client": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz",
|
||||
"integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/utils": "^7.1.1",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"p-retry": "^6.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/components": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz",
|
||||
"integrity": "sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"dequal": "^2.0.3",
|
||||
"preact": "^10.26.10",
|
||||
"pretty-bytes": "^6.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/image-editor": "^4.2.0",
|
||||
"@uppy/screen-capture": "^5.1.0",
|
||||
"@uppy/webcam": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@uppy/image-editor": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/screen-capture": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/webcam": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/core": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz",
|
||||
"integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@transloadit/prettier-bytes": "^0.3.4",
|
||||
"@uppy/store-default": "^5.0.0",
|
||||
"@uppy/utils": "^7.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-match": "^1.0.2",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"preact": "^10.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/core/node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/locales": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/locales/-/locales-5.1.1.tgz",
|
||||
"integrity": "sha512-zSbDU27JzfdssRJoa5/xmGOsrEtS+2Z9j41weaoCa/NoK4wqZzkFNQ0Z44etbTg3PDVFakZVDu/Z+c+vsJCfdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/utils": "^7.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/store-default": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz",
|
||||
"integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uppy/utils": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.1.5.tgz",
|
||||
"integrity": "sha512-Vz4WGTjef6WebECGur4clWjpkET4o3bdvPMj1m2sD5cL+dTt69m+FIE5h5JD3HBMLEPTXPVkrXGMIFcbOYC12Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"preact": "^10.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/vue": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/vue/-/vue-3.2.0.tgz",
|
||||
"integrity": "sha512-BZiyUZxpadf3uUp8YzgwRZTIde/m9Ne4ILFygJg7ilFq/Qfb1pBVspG9FJoG23RbOiRuxd4JixwFh0gaFdfL+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/components": "^1.2.0",
|
||||
"preact": "^10.26.10",
|
||||
"shallow-equal": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.1",
|
||||
"@uppy/screen-capture": "^5.1.0",
|
||||
"@uppy/status-bar": "^5.1.0",
|
||||
"@uppy/webcam": "^5.1.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@uppy/dashboard": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/screen-capture": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/status-bar": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/webcam": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/xhr-upload": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.1.1.tgz",
|
||||
"integrity": "sha512-Vp0HWVA8o+niC2uISxPt0pZ+95bHHkk9HzNaUTrff/vq+20Ln68BS2auJhc9ecJzI6SKAlGZ342dcTQ/onw0nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/companion-client": "^5.1.1",
|
||||
"@uppy/utils": "^7.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@viselect/vanilla": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@viselect/vanilla/-/vanilla-3.9.0.tgz",
|
||||
"integrity": "sha512-E9eBgoi/crJ0SlZMAc+Yst7nU324LZ5LLvcXjzWEcrfllscdpTml2OLOKHC7O8Bbz19OybSLv6VexxnjlJrLxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
||||
@@ -1537,6 +1877,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -1564,12 +1919,27 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1580,6 +1950,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/easy-bem": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
|
||||
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
@@ -1715,6 +2091,18 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
@@ -2039,6 +2427,12 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.564.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.564.0.tgz",
|
||||
@@ -2072,6 +2466,15 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
|
||||
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wildcard": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -2113,6 +2516,12 @@
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/namespace-emitter": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
|
||||
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -2131,6 +2540,44 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanostores": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz",
|
||||
"integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/overlayscrollbars": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz",
|
||||
"integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
|
||||
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "0.12.2",
|
||||
"is-network-error": "^1.0.0",
|
||||
"retry": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2253,6 +2700,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.4",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
|
||||
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -2282,6 +2751,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -2339,6 +2823,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shallow-equal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
|
||||
"integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2469,6 +2959,16 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/vanilla-lazyload": {
|
||||
"version": "19.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vanilla-lazyload/-/vanilla-lazyload-19.1.3.tgz",
|
||||
"integrity": "sha512-bBMERPu2AFJc35krS+8BOhq++c6dRfL6q368lJPnkS5U92fRQagTR3FsNta69/GukfZzDwDEjD5M3U7VuSiCDw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://ko-fi.com/verlok"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -2572,6 +3072,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-advanced-cropper": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.6",
|
||||
"debounce": "^1.2.0",
|
||||
"easy-bem": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
|
||||
@@ -2656,6 +3174,28 @@
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-sonner": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",
|
||||
"integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^4.0.3",
|
||||
"@nuxt/schema": "^4.0.3",
|
||||
"nuxt": "^4.0.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@nuxt/schema": {
|
||||
"optional": true
|
||||
},
|
||||
"nuxt": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||
@@ -2673,12 +3213,57 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vuefinder": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vuefinder/-/vuefinder-4.1.1.tgz",
|
||||
"integrity": "sha512-sRrDj7+jrSN4CCQCkBDhc4Z3ZEP2CHa3HibPsByy/yEj2BVH6G7Fn9fSJ3IjGEivYTdURc6DbusidU6Rg6/SqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@nanostores/i18n": "^1.2.2",
|
||||
"@nanostores/persistent": "^1.1.0",
|
||||
"@nanostores/vue": "^1.0.1",
|
||||
"@tanstack/vue-query": "^5.90.2",
|
||||
"@uppy/core": "^5.0.2",
|
||||
"@uppy/locales": "^5.0.1",
|
||||
"@uppy/vue": "^3.1.0",
|
||||
"@uppy/xhr-upload": "^5.0.1",
|
||||
"@viselect/vanilla": "^3.9.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nanostores": "^1.0.1",
|
||||
"overlayscrollbars": "^2.12.0",
|
||||
"vanilla-lazyload": "^19.1.3",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/n1crack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
|
||||
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
"vue-router": "^5.0.2",
|
||||
"vuefinder": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Crosshair,
|
||||
Navigation2,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -42,6 +45,9 @@ const navItems = [
|
||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
||||
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
|
||||
{ name: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.view' },
|
||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||
@@ -103,7 +109,7 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed md:static inset-y-0 left-0 z-50 transform transition-transform"
|
||||
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||
>
|
||||
<!-- Logo -->
|
||||
@@ -201,8 +207,8 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto md:ml-0">
|
||||
<!-- Main Content (offset by sidebar width on desktop) -->
|
||||
<main class="flex-1 overflow-y-auto md:pl-64">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal file
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
lootTable: Record<string, any>
|
||||
selected: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [prefab: string]
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const categoryIcons: Record<string, any> = {
|
||||
crates: Box,
|
||||
barrels: Cylinder,
|
||||
military: Shield,
|
||||
npcs: Users,
|
||||
other: HelpCircle,
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
crates: 'CRATES',
|
||||
barrels: 'BARRELS',
|
||||
military: 'MILITARY',
|
||||
npcs: 'NPCs',
|
||||
other: 'OTHER',
|
||||
}
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
if (!q) return rustContainers
|
||||
return rustContainers.filter(c => c.name.toLowerCase().includes(q) || c.prefab.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const groupedContainers = computed(() => {
|
||||
const groups: Record<string, typeof rustContainers> = {}
|
||||
for (const cat of containerCategories) {
|
||||
const items = filteredContainers.value.filter(c => c.category === cat)
|
||||
if (items.length > 0) groups[cat] = items
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function isConfigured(prefab: string): boolean {
|
||||
const entry = props.lootTable[prefab]
|
||||
if (!entry) return false
|
||||
const hasItems = entry.UngroupedItems && Object.keys(entry.UngroupedItems).length > 0
|
||||
const hasGuaranteed = entry.GuaranteedItems && Object.keys(entry.GuaranteedItems).length > 0
|
||||
const hasProfiles = entry.LootProfiles && entry.LootProfiles.length > 0
|
||||
return hasItems || hasGuaranteed || hasProfiles
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
||||
<!-- Search -->
|
||||
<div class="p-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search containers..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||
<div class="px-3 pt-3 pb-1">
|
||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
||||
{{ categoryLabels[category] || category }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-for="c in containers"
|
||||
:key="c.prefab"
|
||||
@click="emit('select', c.prefab)"
|
||||
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
||||
:class="selected === c.prefab
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<span class="truncate flex-1">{{ c.name }}</span>
|
||||
<span
|
||||
v-if="isConfigured(c.prefab)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
||||
No containers match
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
184
frontend/src/components/loot/LootGroupEditor.vue
Normal file
184
frontend/src/components/loot/LootGroupEditor.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import type { LootGroupProfile } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
lootGroups: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dirty: []
|
||||
}>()
|
||||
|
||||
const expandedGroup = ref<string | null>(null)
|
||||
const newGroupName = ref('')
|
||||
|
||||
const groupEntries = computed(() => {
|
||||
return Object.entries(props.lootGroups).map(([name, data]) => ({
|
||||
name,
|
||||
data: data as LootGroupProfile,
|
||||
itemCount: data?.ItemList ? Object.keys(data.ItemList).length : 0,
|
||||
}))
|
||||
})
|
||||
|
||||
function toggleGroup(name: string) {
|
||||
expandedGroup.value = expandedGroup.value === name ? null : name
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name || props.lootGroups[name]) return
|
||||
props.lootGroups[name] = {
|
||||
Enabled: true,
|
||||
GuaranteedItems: {},
|
||||
ItemList: {},
|
||||
}
|
||||
newGroupName.value = ''
|
||||
expandedGroup.value = name
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
function deleteGroup(name: string) {
|
||||
if (!confirm(`Delete group "${name}"?`)) return
|
||||
delete props.lootGroups[name]
|
||||
if (expandedGroup.value === name) expandedGroup.value = null
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
function getItemName(shortname: string): string {
|
||||
return rustItems.find(i => i.shortname === shortname)?.name || shortname
|
||||
}
|
||||
|
||||
function removeItemFromGroup(groupName: string, shortname: string) {
|
||||
delete props.lootGroups[groupName].ItemList[shortname]
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
function updateGroupItemField(groupName: string, shortname: string, field: string, value: number) {
|
||||
if (props.lootGroups[groupName]?.ItemList?.[shortname]) {
|
||||
props.lootGroups[groupName].ItemList[shortname][field] = value
|
||||
emit('dirty')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Add Group -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group List -->
|
||||
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in groupEntries"
|
||||
:key="entry.name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<button
|
||||
@click="toggleGroup(entry.name)"
|
||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<component
|
||||
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
||||
class="w-4 h-4 text-neutral-500"
|
||||
/>
|
||||
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
||||
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="deleteGroup(entry.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<!-- Group Items -->
|
||||
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
||||
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||
:key="shortname"
|
||||
class="border-b border-neutral-800/50"
|
||||
>
|
||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Min ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Max ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Probability ?? 100"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||
class="text-neutral-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
||||
No items in this group yet. Add items from the container editor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
232
frontend/src/components/loot/LootItemEditor.vue
Normal file
232
frontend/src/components/loot/LootItemEditor.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { rustContainers } from '@/data/rust-containers'
|
||||
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
||||
import type { PrefabLoot } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
containerKey: string
|
||||
lootTable: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dirty: []
|
||||
'add-item': []
|
||||
}>()
|
||||
|
||||
const containerName = computed(() => {
|
||||
const c = rustContainers.find(c => c.prefab === props.containerKey)
|
||||
return c?.name || props.containerKey.split('/').pop()?.replace('.prefab', '') || 'Unknown'
|
||||
})
|
||||
|
||||
const containerData = computed<PrefabLoot | null>(() => {
|
||||
return props.lootTable[props.containerKey] || null
|
||||
})
|
||||
|
||||
function ensureContainer() {
|
||||
if (!props.lootTable[props.containerKey]) {
|
||||
props.lootTable[props.containerKey] = {
|
||||
Enabled: true,
|
||||
LootProfiles: [],
|
||||
GuaranteedItems: {},
|
||||
UngroupedItems: {},
|
||||
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
|
||||
}
|
||||
emit('dirty')
|
||||
}
|
||||
}
|
||||
|
||||
function getItemName(shortname: string): string {
|
||||
return rustItems.find(i => i.shortname === shortname)?.name || shortname
|
||||
}
|
||||
|
||||
function updateItemField(shortname: string, field: string, value: number) {
|
||||
ensureContainer()
|
||||
const items = props.lootTable[props.containerKey].UngroupedItems
|
||||
if (items[shortname]) {
|
||||
items[shortname][field] = value
|
||||
emit('dirty')
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettings(field: string, value: number) {
|
||||
ensureContainer()
|
||||
props.lootTable[props.containerKey].ItemSettings[field] = value
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
ensureContainer()
|
||||
props.lootTable[props.containerKey].Enabled = !props.lootTable[props.containerKey].Enabled
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
function removeItem(shortname: string) {
|
||||
if (!containerData.value?.UngroupedItems) return
|
||||
delete props.lootTable[props.containerKey].UngroupedItems[shortname]
|
||||
emit('dirty')
|
||||
}
|
||||
|
||||
const ungroupedItems = computed(() => {
|
||||
if (!containerData.value?.UngroupedItems) return []
|
||||
return Object.entries(containerData.value.UngroupedItems).map(([shortname, data]) => ({
|
||||
shortname,
|
||||
name: getItemName(shortname),
|
||||
...(data as any),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Container Header -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="containerData?.Enabled ?? true"
|
||||
@change="toggleEnabled"
|
||||
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings2 class="w-4 h-4 text-neutral-500" />
|
||||
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Settings -->
|
||||
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ungrouped Items Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
||||
<button
|
||||
@click="emit('add-item')"
|
||||
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in ungroupedItems"
|
||||
:key="item.shortname"
|
||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
||||
>
|
||||
<td class="py-2 px-2">
|
||||
<div>
|
||||
<span class="text-neutral-200">{{ item.name }}</span>
|
||||
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Min"
|
||||
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Max"
|
||||
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Probability ?? 100"
|
||||
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
@click="removeItem(item.shortname)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
||||
No items configured for this container.
|
||||
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
88
frontend/src/components/loot/LootItemPicker.vue
Normal file
88
frontend/src/components/loot/LootItemPicker.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [shortname: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref<string>('all')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = rustItems
|
||||
if (selectedCategory.value !== 'all') {
|
||||
items = items.filter(i => i.category === selectedCategory.value)
|
||||
}
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
if (q) {
|
||||
items = items.filter(i => i.name.toLowerCase().includes(q) || i.shortname.toLowerCase().includes(q))
|
||||
}
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
||||
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<div class="p-4 space-y-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search items..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
@click="selectedCategory = 'all'"
|
||||
class="px-2 py-1 rounded text-xs"
|
||||
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in itemCategories"
|
||||
:key="cat"
|
||||
@click="selectedCategory = cat"
|
||||
class="px-2 py-1 rounded text-xs capitalize"
|
||||
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.shortname"
|
||||
@click="emit('select', item.shortname)"
|
||||
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
||||
>
|
||||
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
||||
No items found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal file
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
configData: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:configData': [configData: Record<string, any>]
|
||||
}>()
|
||||
|
||||
const newGroupName = ref('')
|
||||
|
||||
// Merge all VIP maps by key name to compute the unified group list
|
||||
const groups = computed(() => {
|
||||
const homesLimits: Record<string, number> = props.configData?.Home?.VIPHomesLimits || {}
|
||||
const cooldowns: Record<string, number> = props.configData?.TPR?.VIPCooldowns || {}
|
||||
const countdowns: Record<string, number> = props.configData?.TPR?.VIPCountdowns || {}
|
||||
const dailyLimits: Record<string, number> = props.configData?.TPR?.VIPDailyLimits || {}
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(homesLimits),
|
||||
...Object.keys(cooldowns),
|
||||
...Object.keys(countdowns),
|
||||
...Object.keys(dailyLimits),
|
||||
])
|
||||
|
||||
return Array.from(allKeys).map(name => ({
|
||||
name,
|
||||
homesLimit: homesLimits[name] ?? 5,
|
||||
cooldown: cooldowns[name] ?? 300,
|
||||
countdown: countdowns[name] ?? 5,
|
||||
dailyLimit: dailyLimits[name] ?? 10,
|
||||
}))
|
||||
})
|
||||
|
||||
function ensurePaths(data: Record<string, any>) {
|
||||
if (!data.Home) data.Home = {}
|
||||
if (!data.Home.VIPHomesLimits) data.Home.VIPHomesLimits = {}
|
||||
if (!data.TPR) data.TPR = {}
|
||||
if (!data.TPR.VIPCooldowns) data.TPR.VIPCooldowns = {}
|
||||
if (!data.TPR.VIPCountdowns) data.TPR.VIPCountdowns = {}
|
||||
if (!data.TPR.VIPDailyLimits) data.TPR.VIPDailyLimits = {}
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name) return
|
||||
// Check if already exists
|
||||
if (groups.value.some(g => g.name === name)) return
|
||||
|
||||
const updated = { ...props.configData }
|
||||
ensurePaths(updated)
|
||||
updated.Home.VIPHomesLimits[name] = 5
|
||||
updated.TPR.VIPCooldowns[name] = 300
|
||||
updated.TPR.VIPCountdowns[name] = 5
|
||||
updated.TPR.VIPDailyLimits[name] = 10
|
||||
emit('update:configData', updated)
|
||||
newGroupName.value = ''
|
||||
}
|
||||
|
||||
function removeGroup(name: string) {
|
||||
if (!confirm(`Remove VIP group "${name}"?`)) return
|
||||
const updated = { ...props.configData }
|
||||
ensurePaths(updated)
|
||||
delete updated.Home.VIPHomesLimits[name]
|
||||
delete updated.TPR.VIPCooldowns[name]
|
||||
delete updated.TPR.VIPCountdowns[name]
|
||||
delete updated.TPR.VIPDailyLimits[name]
|
||||
emit('update:configData', updated)
|
||||
}
|
||||
|
||||
function updateField(groupName: string, field: string, value: number) {
|
||||
const updated = { ...props.configData }
|
||||
ensurePaths(updated)
|
||||
|
||||
switch (field) {
|
||||
case 'homesLimit':
|
||||
updated.Home.VIPHomesLimits[groupName] = value
|
||||
break
|
||||
case 'cooldown':
|
||||
updated.TPR.VIPCooldowns[groupName] = value
|
||||
break
|
||||
case 'countdown':
|
||||
updated.TPR.VIPCountdowns[groupName] = value
|
||||
break
|
||||
case 'dailyLimit':
|
||||
updated.TPR.VIPDailyLimits[groupName] = value
|
||||
break
|
||||
}
|
||||
|
||||
emit('update:configData', updated)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
||||
</div>
|
||||
|
||||
<!-- Add Group -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
||||
</div>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
class="border-b border-neutral-800/50"
|
||||
>
|
||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
||||
<td class="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
:value="group.homesLimit"
|
||||
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
:value="group.cooldown"
|
||||
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
:value="group.countdown"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
:value="group.dailyLimit"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<button
|
||||
@click="removeGroup(group.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
frontend/src/components/teleport/WarpEditor.vue
Normal file
76
frontend/src/components/teleport/WarpEditor.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
warps: Record<string, { x: number; y: number; z: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:warps': [warps: Record<string, { x: number; y: number; z: number }>]
|
||||
}>()
|
||||
|
||||
const newWarpName = ref('')
|
||||
|
||||
function addWarp() {
|
||||
const name = newWarpName.value.trim()
|
||||
if (!name || props.warps[name]) return
|
||||
const updated = { ...props.warps, [name]: { x: 0, y: 0, z: 0 } }
|
||||
emit('update:warps', updated)
|
||||
newWarpName.value = ''
|
||||
}
|
||||
|
||||
function removeWarp(name: string) {
|
||||
const updated = { ...props.warps }
|
||||
delete updated[name]
|
||||
emit('update:warps', updated)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
||||
|
||||
<!-- Add Warp -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newWarpName"
|
||||
placeholder="Warp name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
@keydown.enter="addWarp"
|
||||
/>
|
||||
<button
|
||||
@click="addWarp"
|
||||
:disabled="!newWarpName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Warp List -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
||||
No warps defined. Add warps here and set coordinates in-game.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(coords, name) in warps"
|
||||
:key="name"
|
||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-3">
|
||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="removeWarp(name as string)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -107,5 +107,40 @@ export function useApi() {
|
||||
return request<T>(path, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { request, get, post, put, del }
|
||||
/**
|
||||
* Upload a FormData payload (multipart/form-data).
|
||||
* Does NOT set Content-Type — browser must set it with the correct boundary.
|
||||
*/
|
||||
async function upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth.refreshToken) {
|
||||
await attemptRefresh()
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return { request, get, post, put, del, upload }
|
||||
}
|
||||
|
||||
72
frontend/src/data/rust-containers.ts
Normal file
72
frontend/src/data/rust-containers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface RustContainer {
|
||||
prefab: string
|
||||
name: string
|
||||
category: 'crates' | 'barrels' | 'military' | 'npcs' | 'other'
|
||||
}
|
||||
|
||||
export const rustContainers: RustContainer[] = [
|
||||
// Crates
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_fuel.prefab', name: 'Fuel Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'crates' },
|
||||
|
||||
// Barrels
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot-barrel-1.prefab', name: 'Barrel (Alt)', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot-barrel-2.prefab', name: 'Barrel 2 (Alt)', category: 'barrels' },
|
||||
|
||||
// Military
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2_food.prefab', name: 'Military Food Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2_medical.prefab', name: 'Military Medical Crate', category: 'military' },
|
||||
|
||||
// NPCs
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_patrol.prefab', name: 'Patrol Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_junkpile.prefab', name: 'Junkpile Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_peacekeeper.prefab', name: 'Peacekeeper', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_ch47_gunner.prefab', name: 'Chinook Gunner', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/underwaterdweller/npc_underwaterdweller.prefab', name: 'Underwater Dweller', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scarecrow/scarecrow.prefab', name: 'Scarecrow', category: 'npcs' },
|
||||
|
||||
// Other
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm ammo.prefab', name: 'DM Ammo', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm c4.prefab', name: 'DM C4', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm construction resources.prefab', name: 'DM Construction', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm construction tools.prefab', name: 'DM Tools', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm food.prefab', name: 'DM Food', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm medical.prefab', name: 'DM Medical', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm res.prefab', name: 'DM Resources', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier1 lootbox.prefab', name: 'DM Tier 1 Lootbox', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier2 lootbox.prefab', name: 'DM Tier 2 Lootbox', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier3 lootbox.prefab', name: 'DM Tier 3 Lootbox', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_food_1_underwater_lab.prefab', name: 'Lab Food Crate', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_normal_underwater_lab.prefab', name: 'Lab Crate', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_elite_underwater_lab.prefab', name: 'Lab Elite Crate', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_tools_underwater_lab.prefab', name: 'Lab Tool Crate', category: 'other' },
|
||||
]
|
||||
|
||||
export const containerCategories = ['crates', 'barrels', 'military', 'npcs', 'other'] as const
|
||||
|
||||
export type ContainerCategory = typeof containerCategories[number]
|
||||
259
frontend/src/data/rust-items.ts
Normal file
259
frontend/src/data/rust-items.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
export interface RustItem {
|
||||
shortname: string
|
||||
name: string
|
||||
category: 'weapons' | 'ammo' | 'medical' | 'attire' | 'tools' | 'resources' | 'components' | 'food' | 'traps' | 'construction' | 'electrical' | 'fun' | 'misc'
|
||||
maxStack: number
|
||||
}
|
||||
|
||||
export const rustItems: RustItem[] = [
|
||||
// Weapons
|
||||
{ shortname: 'rifle.ak', name: 'Assault Rifle', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rifle.lr300', name: 'LR-300', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rifle.bolt', name: 'Bolt Action Rifle', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rifle.m39', name: 'M39 Rifle', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rifle.semiauto', name: 'Semi-Auto Rifle', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rifle.l96', name: 'L96 Rifle', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'smg.mp5', name: 'MP5A4', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'smg.thompson', name: 'Thompson', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'smg.2', name: 'Custom SMG', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.revolver', name: 'Revolver', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.semiauto', name: 'Semi-Auto Pistol', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.python', name: 'Python Revolver', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.m92', name: 'M92 Pistol', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.nailgun', name: 'Nailgun', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'shotgun.pump', name: 'Pump Shotgun', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'shotgun.spas12', name: 'Spas-12', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'shotgun.double', name: 'Double Barrel Shotgun', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'shotgun.waterpipe', name: 'Waterpipe Shotgun', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'lmg.m249', name: 'M249', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'rocket.launcher', name: 'Rocket Launcher', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'multiplegrenadelauncher', name: 'Multiple Grenade Launcher', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.lasersight', name: 'Laser Sight', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.holosight', name: 'Holosight', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.flashlight', name: 'Flashlight', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.silencer', name: 'Silencer', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.simplesight', name: 'Simple Handmade Sight', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.small.scope', name: 'Handmade Scope', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.8x.scope', name: '8x Zoom Scope', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.muzzleboost', name: 'Muzzle Boost', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'weapon.mod.muzzlebrake', name: 'Muzzle Brake', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'crossbow', name: 'Crossbow', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'bow.hunting', name: 'Hunting Bow', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'bow.compound', name: 'Compound Bow', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'spear.wooden', name: 'Wooden Spear', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'spear.stone', name: 'Stone Spear', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'machete', name: 'Machete', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'longsword', name: 'Longsword', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'salvaged.sword', name: 'Salvaged Sword', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'salvaged.cleaver', name: 'Salvaged Cleaver', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'knife.combat', name: 'Combat Knife', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'bone.club', name: 'Bone Club', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'mace', name: 'Mace', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'grenade.f1', name: 'F1 Grenade', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'grenade.beancan', name: 'Beancan Grenade', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'explosive.satchel', name: 'Satchel Charge', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'explosive.timed', name: 'Timed Explosive', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'surveycharge', name: 'Survey Charge', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'flare', name: 'Flare', category: 'weapons', maxStack: 1 },
|
||||
{ shortname: 'pistol.eoka', name: 'Eoka Pistol', category: 'weapons', maxStack: 1 },
|
||||
|
||||
// Ammo
|
||||
{ shortname: 'ammo.rifle', name: '5.56 Rifle Ammo', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.rifle.hv', name: 'HV 5.56 Rifle Ammo', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.rifle.incendiary', name: 'Incendiary 5.56 Ammo', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.rifle.explosive', name: 'Explosive 5.56 Ammo', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.pistol', name: 'Pistol Bullet', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.pistol.hv', name: 'HV Pistol Ammo', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.pistol.fire', name: 'Incendiary Pistol Bullet', category: 'ammo', maxStack: 128 },
|
||||
{ shortname: 'ammo.shotgun', name: 'Handmade Shell', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'ammo.shotgun.slug', name: '12 Gauge Slug', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'ammo.shotgun.fire', name: '12 Gauge Incendiary Shell', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'ammo.rocket.basic', name: 'Rocket', category: 'ammo', maxStack: 3 },
|
||||
{ shortname: 'ammo.rocket.hv', name: 'HV Rocket', category: 'ammo', maxStack: 3 },
|
||||
{ shortname: 'ammo.rocket.fire', name: 'Incendiary Rocket', category: 'ammo', maxStack: 3 },
|
||||
{ shortname: 'arrow.wooden', name: 'Wooden Arrow', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'arrow.hv', name: 'High Velocity Arrow', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'arrow.fire', name: 'Fire Arrow', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'arrow.bone', name: 'Bone Arrow', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'ammo.nailgun.nails', name: 'Nailgun Nails', category: 'ammo', maxStack: 64 },
|
||||
{ shortname: 'ammo.grenadelauncher.he', name: '40mm HE Grenade', category: 'ammo', maxStack: 12 },
|
||||
{ shortname: 'ammo.grenadelauncher.smoke', name: '40mm Smoke Grenade', category: 'ammo', maxStack: 12 },
|
||||
|
||||
// Medical
|
||||
{ shortname: 'syringe.medical', name: 'Medical Syringe', category: 'medical', maxStack: 3 },
|
||||
{ shortname: 'largemedkit', name: 'Large Medkit', category: 'medical', maxStack: 1 },
|
||||
{ shortname: 'bandage', name: 'Bandage', category: 'medical', maxStack: 3 },
|
||||
{ shortname: 'antiradpills', name: 'Anti-Radiation Pills', category: 'medical', maxStack: 10 },
|
||||
{ shortname: 'blood', name: 'Blood', category: 'medical', maxStack: 1 },
|
||||
|
||||
// Attire
|
||||
{ shortname: 'metal.facemask', name: 'Metal Facemask', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'metal.plate.torso', name: 'Metal Chest Plate', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'roadsign.jacket', name: 'Roadsign Jacket', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'roadsign.kilt', name: 'Roadsign Kilt', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'coffeecan.helmet', name: 'Coffee Can Helmet', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'riot.helmet', name: 'Riot Helmet', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'bucket.helmet', name: 'Bucket Helmet', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hoodie', name: 'Hoodie', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'pants', name: 'Pants', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'shoes.boots', name: 'Boots', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'burlap.shirt', name: 'Burlap Shirt', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'burlap.trousers', name: 'Burlap Trousers', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'burlap.shoes', name: 'Burlap Shoes', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'burlap.headwrap', name: 'Burlap Headwrap', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'burlap.gloves', name: 'Burlap Gloves', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hat.wolf', name: 'Wolf Headdress', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hat.boonie', name: 'Boonie Hat', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hat.beenie', name: 'Beenie Hat', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hat.miner', name: 'Miners Hat', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hat.candle', name: 'Candle Hat', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'attire.hide.poncho', name: 'Hide Poncho', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'attire.hide.vest', name: 'Hide Vest', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'attire.hide.boots', name: 'Hide Boots', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'attire.hide.pants', name: 'Hide Pants', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'attire.hide.skirt', name: 'Hide Skirt', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'deer.skull.mask', name: 'Deer Skull Mask', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'bone.armor.suit', name: 'Bone Armor', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'heavy.plate.helmet', name: 'Heavy Plate Helmet', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'heavy.plate.jacket', name: 'Heavy Plate Jacket', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'heavy.plate.pants', name: 'Heavy Plate Pants', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'hazmatsuit', name: 'Hazmat Suit', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'nightvisiongoggles', name: 'Night Vision Goggles', category: 'attire', maxStack: 1 },
|
||||
{ shortname: 'tactical.gloves', name: 'Tactical Gloves', category: 'attire', maxStack: 1 },
|
||||
|
||||
// Tools
|
||||
{ shortname: 'hatchet', name: 'Hatchet', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'pickaxe', name: 'Pickaxe', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'stone.pickaxe', name: 'Stone Pickaxe', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'stonehatchet', name: 'Stone Hatchet', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'rock', name: 'Rock', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'torch', name: 'Torch', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'jackhammer', name: 'Jackhammer', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'chainsaw', name: 'Chainsaw', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'hammer', name: 'Hammer', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'wire.cutter', name: 'Wire Cutter', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'tool.binoculars', name: 'Binoculars', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'tool.camera', name: 'Camera', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'geiger.counter', name: 'Geiger Counter', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'supply.signal', name: 'Supply Signal', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'map', name: 'Map', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'note', name: 'Note', category: 'tools', maxStack: 1 },
|
||||
{ shortname: 'blueprintbase', name: 'Blueprint', category: 'tools', maxStack: 1 },
|
||||
|
||||
// Resources
|
||||
{ shortname: 'wood', name: 'Wood', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'stones', name: 'Stones', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'metal.ore', name: 'Metal Ore', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'metal.fragments', name: 'Metal Fragments', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'metal.refined', name: 'High Quality Metal', category: 'resources', maxStack: 100 },
|
||||
{ shortname: 'sulfur.ore', name: 'Sulfur Ore', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'sulfur', name: 'Sulfur', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'gunpowder', name: 'Gun Powder', category: 'resources', maxStack: 500 },
|
||||
{ shortname: 'explosives', name: 'Explosives', category: 'resources', maxStack: 10 },
|
||||
{ shortname: 'charcoal', name: 'Charcoal', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'lowgradefuel', name: 'Low Grade Fuel', category: 'resources', maxStack: 500 },
|
||||
{ shortname: 'crude.oil', name: 'Crude Oil', category: 'resources', maxStack: 500 },
|
||||
{ shortname: 'leather', name: 'Leather', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'cloth', name: 'Cloth', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'fat.animal', name: 'Animal Fat', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'bone.fragments', name: 'Bone Fragments', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'scrap', name: 'Scrap', category: 'resources', maxStack: 1000 },
|
||||
{ shortname: 'diesel_barrel', name: 'Diesel Fuel', category: 'resources', maxStack: 20 },
|
||||
|
||||
// Components
|
||||
{ shortname: 'riflebody', name: 'Rifle Body', category: 'components', maxStack: 1 },
|
||||
{ shortname: 'smgbody', name: 'SMG Body', category: 'components', maxStack: 1 },
|
||||
{ shortname: 'semibody', name: 'Semi Auto Body', category: 'components', maxStack: 1 },
|
||||
{ shortname: 'metalpipe', name: 'Metal Pipe', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'metalspring', name: 'Metal Spring', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'gears', name: 'Gears', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'roadsigns', name: 'Road Signs', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'sewingkit', name: 'Sewing Kit', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'tarp', name: 'Tarp', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'rope', name: 'Rope', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'sheetmetal', name: 'Sheet Metal', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'techparts', name: 'Tech Trash', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'propanetank', name: 'Propane Tank', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'targeting.computer', name: 'Targeting Computer', category: 'components', maxStack: 1 },
|
||||
{ shortname: 'cctv.camera', name: 'CCTV Camera', category: 'components', maxStack: 1 },
|
||||
{ shortname: 'electric.fuse', name: 'Fuse', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'bleach', name: 'Bleach', category: 'components', maxStack: 5 },
|
||||
{ shortname: 'ducttape', name: 'Duct Tape', category: 'components', maxStack: 5 },
|
||||
|
||||
// Food
|
||||
{ shortname: 'apple', name: 'Apple', category: 'food', maxStack: 10 },
|
||||
{ shortname: 'granolabar', name: 'Granola Bar', category: 'food', maxStack: 10 },
|
||||
{ shortname: 'can.beans', name: 'Can of Beans', category: 'food', maxStack: 10 },
|
||||
{ shortname: 'can.tuna', name: 'Can of Tuna', category: 'food', maxStack: 10 },
|
||||
{ shortname: 'chocbar', name: 'Chocolate Bar', category: 'food', maxStack: 10 },
|
||||
{ shortname: 'mushroom', name: 'Mushroom', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'meat.boar', name: 'Boar Meat', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'chicken.raw', name: 'Raw Chicken', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'humanmeat.raw', name: 'Raw Human Meat', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'wolfmeat.raw', name: 'Raw Wolf Meat', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'deermeat.raw', name: 'Raw Deer Meat', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'bearmeat', name: 'Bear Meat', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'fish.raw', name: 'Raw Fish', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'corn', name: 'Corn', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'pumpkin', name: 'Pumpkin', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'potato', name: 'Potato', category: 'food', maxStack: 20 },
|
||||
{ shortname: 'waterjug', name: 'Water Jug', category: 'food', maxStack: 1 },
|
||||
{ shortname: 'water', name: 'Water', category: 'food', maxStack: 1 },
|
||||
{ shortname: 'water.purified', name: 'Pure Water', category: 'food', maxStack: 10 },
|
||||
|
||||
// Traps
|
||||
{ shortname: 'trap.bear', name: 'Snap Trap', category: 'traps', maxStack: 3 },
|
||||
{ shortname: 'trap.landmine', name: 'Landmine', category: 'traps', maxStack: 3 },
|
||||
{ shortname: 'autoturret', name: 'Auto Turret', category: 'traps', maxStack: 1 },
|
||||
{ shortname: 'flameturret', name: 'Flame Turret', category: 'traps', maxStack: 1 },
|
||||
{ shortname: 'guntrap', name: 'Shotgun Trap', category: 'traps', maxStack: 1 },
|
||||
{ shortname: 'sam.site', name: 'SAM Site', category: 'traps', maxStack: 1 },
|
||||
|
||||
// Construction
|
||||
{ shortname: 'wall.external.high', name: 'High External Wall', category: 'construction', maxStack: 10 },
|
||||
{ shortname: 'wall.external.high.stone', name: 'High External Stone Wall', category: 'construction', maxStack: 10 },
|
||||
{ shortname: 'gates.external.high.wood', name: 'High External Wooden Gate', category: 'construction', maxStack: 1 },
|
||||
{ shortname: 'gates.external.high.stone', name: 'High External Stone Gate', category: 'construction', maxStack: 1 },
|
||||
{ shortname: 'barricade.metal', name: 'Metal Barricade', category: 'construction', maxStack: 3 },
|
||||
{ shortname: 'barricade.sandbags', name: 'Sandbag Barricade', category: 'construction', maxStack: 5 },
|
||||
{ shortname: 'barricade.concrete', name: 'Concrete Barricade', category: 'construction', maxStack: 3 },
|
||||
{ shortname: 'barricade.wood', name: 'Wooden Barricade', category: 'construction', maxStack: 5 },
|
||||
{ shortname: 'barricade.woodwire', name: 'Barbed Wooden Barricade', category: 'construction', maxStack: 3 },
|
||||
{ shortname: 'lock.code', name: 'Code Lock', category: 'construction', maxStack: 1 },
|
||||
{ shortname: 'lock.key', name: 'Key Lock', category: 'construction', maxStack: 1 },
|
||||
|
||||
// Misc
|
||||
{ shortname: 'workbench1', name: 'Work Bench Level 1', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'workbench2', name: 'Work Bench Level 2', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'workbench3', name: 'Work Bench Level 3', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'furnace', name: 'Furnace', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'furnace.large', name: 'Large Furnace', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'campfire', name: 'Camp Fire', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'box.wooden', name: 'Wood Storage Box', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'box.wooden.large', name: 'Large Wood Box', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'cupboard.tool', name: 'Tool Cupboard', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'sleepingbag', name: 'Sleeping Bag', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'bed', name: 'Bed', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'research.table', name: 'Research Table', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'mining.quarry', name: 'Mining Quarry', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'small.oil.refinery', name: 'Small Oil Refinery', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'water.purifier', name: 'Water Purifier', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'stocking.small', name: 'Small Stocking', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'stocking.large', name: 'Large Stocking', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'kayak', name: 'Kayak', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'fridge', name: 'Fridge', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'locker', name: 'Locker', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'vending.machine', name: 'Vending Machine', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'wall.frame.shopfront', name: 'Shop Front', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'door.hinged.metal', name: 'Sheet Metal Door', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'door.hinged.toptier', name: 'Armored Door', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'door.double.hinged.metal', name: 'Sheet Metal Double Door', category: 'misc', maxStack: 1 },
|
||||
{ shortname: 'door.double.hinged.toptier', name: 'Armored Double Door', category: 'misc', maxStack: 1 },
|
||||
]
|
||||
|
||||
export const itemCategories = [
|
||||
'weapons', 'ammo', 'medical', 'attire', 'tools', 'resources',
|
||||
'components', 'food', 'traps', 'construction', 'electrical', 'fun', 'misc',
|
||||
] as const
|
||||
|
||||
export type ItemCategory = typeof itemCategories[number]
|
||||
@@ -2,9 +2,11 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
import { VueFinderPlugin } from 'vuefinder'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import 'vuefinder/dist/vuefinder.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -13,5 +15,6 @@ pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(VueFinderPlugin)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -105,6 +105,21 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
name: 'plugins',
|
||||
component: () => import('@/views/admin/PluginsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'loot-builder',
|
||||
name: 'loot-builder',
|
||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'teleport-config',
|
||||
name: 'teleport-config',
|
||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wipes',
|
||||
name: 'wipes',
|
||||
|
||||
179
frontend/src/stores/loot.ts
Normal file
179
frontend/src/stores/loot.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { LootProfileSummary, LootProfileFull, LootApplyResult } from '@/types'
|
||||
|
||||
export const useLootStore = defineStore('loot', () => {
|
||||
const profiles = ref<LootProfileSummary[]>([])
|
||||
const currentProfile = ref<LootProfileFull | null>(null)
|
||||
const selectedContainer = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isApplying = ref(false)
|
||||
const isDirty = ref(false)
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const activeProfile = computed(() => profiles.value.find(p => p.is_active) || null)
|
||||
|
||||
async function fetchProfiles() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.get<{ profiles: LootProfileSummary[] }>('/loot/profiles')
|
||||
profiles.value = res.profiles
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile(id: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.get<{ profile: LootProfileFull }>(`/loot/profiles/${id}`)
|
||||
currentProfile.value = res.profile
|
||||
isDirty.value = false
|
||||
// Select first container if none selected
|
||||
if (!selectedContainer.value && currentProfile.value.loot_table) {
|
||||
const keys = Object.keys(currentProfile.value.loot_table)
|
||||
if (keys.length > 0) selectedContainer.value = keys[0] ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProfile(name: string, description?: string) {
|
||||
try {
|
||||
const res = await api.post<{ profile: LootProfileFull }>('/loot/profiles', {
|
||||
profile_name: name,
|
||||
description,
|
||||
})
|
||||
await fetchProfiles()
|
||||
currentProfile.value = res.profile
|
||||
isDirty.value = false
|
||||
toast.success(`Profile "${name}" created`)
|
||||
return res.profile
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentProfile() {
|
||||
if (!currentProfile.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
await api.put(`/loot/profiles/${currentProfile.value.id}`, {
|
||||
profile_name: currentProfile.value.profile_name,
|
||||
description: currentProfile.value.description,
|
||||
loot_table: currentProfile.value.loot_table,
|
||||
loot_groups: currentProfile.value.loot_groups,
|
||||
})
|
||||
isDirty.value = false
|
||||
await fetchProfiles()
|
||||
toast.success('Profile saved')
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProfile(id: string) {
|
||||
try {
|
||||
await api.del(`/loot/profiles/${id}`)
|
||||
if (currentProfile.value?.id === id) {
|
||||
currentProfile.value = null
|
||||
selectedContainer.value = null
|
||||
}
|
||||
await fetchProfiles()
|
||||
toast.success('Profile deleted')
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateProfile(id: string) {
|
||||
try {
|
||||
const res = await api.post<{ profile: LootProfileFull }>(`/loot/profiles/${id}/duplicate`)
|
||||
await fetchProfiles()
|
||||
toast.success(`Profile duplicated as "${res.profile.profile_name}"`)
|
||||
return res.profile
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function applyToServer(id: string, multiplier: number) {
|
||||
isApplying.value = true
|
||||
try {
|
||||
const res = await api.post<LootApplyResult>(`/loot/profiles/${id}/apply`, { multiplier })
|
||||
await fetchProfiles()
|
||||
toast.success(res.message)
|
||||
return res
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
isApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function importProfile(name: string, lootTable: Record<string, any>, lootGroups?: Record<string, any>) {
|
||||
try {
|
||||
const res = await api.post<{ profile: LootProfileFull }>('/loot/import', {
|
||||
profile_name: name,
|
||||
loot_table: lootTable,
|
||||
loot_groups: lootGroups || {},
|
||||
})
|
||||
await fetchProfiles()
|
||||
toast.success(`Profile "${name}" imported`)
|
||||
return res.profile
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function exportProfile(id: string, multiplier: number) {
|
||||
try {
|
||||
return await api.get<{ profile_name: string; multiplier: number; loot_table: any; loot_groups: any }>(
|
||||
`/loot/export/${id}?multiplier=${multiplier}`,
|
||||
)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
currentProfile,
|
||||
selectedContainer,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isApplying,
|
||||
isDirty,
|
||||
activeProfile,
|
||||
fetchProfiles,
|
||||
loadProfile,
|
||||
createProfile,
|
||||
saveCurrentProfile,
|
||||
deleteProfile,
|
||||
duplicateProfile,
|
||||
applyToServer,
|
||||
importProfile,
|
||||
exportProfile,
|
||||
markDirty,
|
||||
}
|
||||
})
|
||||
@@ -3,10 +3,39 @@ import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { PluginEntry } from '@/types'
|
||||
|
||||
export interface UmodPlugin {
|
||||
name: string
|
||||
title: string
|
||||
slug: string
|
||||
author: string
|
||||
description: string
|
||||
downloads: number
|
||||
downloads_shortened: string
|
||||
download_url: string
|
||||
latest_release_version: string
|
||||
latest_release_version_formatted: string
|
||||
icon_url: string
|
||||
url: string
|
||||
tags_all: string
|
||||
watchers_shortened: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UmodBrowseResult {
|
||||
current_page: number
|
||||
data: UmodPlugin[]
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const usePluginStore = defineStore('plugins', () => {
|
||||
const plugins = ref<PluginEntry[]>([])
|
||||
const searchResults = ref<any[]>([])
|
||||
const browseResults = ref<UmodBrowseResult | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isBrowseLoading = ref(false)
|
||||
const api = useApi()
|
||||
|
||||
async function fetchPlugins() {
|
||||
@@ -18,7 +47,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(data: { plugin_name: string; source: string }) {
|
||||
async function installPlugin(data: { plugin_name: string; umod_slug?: string; source: string }) {
|
||||
await api.post('/plugins/install', data)
|
||||
await fetchPlugins()
|
||||
}
|
||||
@@ -37,6 +66,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
await fetchPlugins()
|
||||
}
|
||||
|
||||
// Legacy — kept for backwards compatibility, routes to browse
|
||||
async function searchPlugins(query: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -46,15 +76,38 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function browseUmod(query: string, page = 1, sort = 'downloads') {
|
||||
isBrowseLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), sort })
|
||||
if (query.trim()) params.set('query', query.trim())
|
||||
browseResults.value = await api.get<UmodBrowseResult>(`/plugins/browse?${params.toString()}`)
|
||||
} finally {
|
||||
isBrowseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPlugin(file: File): Promise<PluginEntry> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await api.upload<PluginEntry>('/plugins/upload', formData)
|
||||
await fetchPlugins()
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
searchResults,
|
||||
browseResults,
|
||||
isLoading,
|
||||
isBrowseLoading,
|
||||
fetchPlugins,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
reloadPlugin,
|
||||
updatePluginConfig,
|
||||
searchPlugins,
|
||||
browseUmod,
|
||||
uploadPlugin,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ServerConnection, ServerConfig, ServerStats } from '@/types'
|
||||
import type { ServerConnection, ServerConfig, ServerStats, DeploymentConfig, DeploymentStatus } from '@/types'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
export const useServerStore = defineStore('server', () => {
|
||||
@@ -8,6 +8,8 @@ export const useServerStore = defineStore('server', () => {
|
||||
const config = ref<ServerConfig | null>(null)
|
||||
const stats = ref<ServerStats | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const deploymentStatus = ref<DeploymentStatus | null>(null)
|
||||
const isDeploying = ref(false)
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -50,6 +52,39 @@ export const useServerStore = defineStore('server', () => {
|
||||
return api.post('/servers/restart')
|
||||
}
|
||||
|
||||
async function deployServer(config: DeploymentConfig) {
|
||||
isDeploying.value = true
|
||||
deploymentStatus.value = null
|
||||
try {
|
||||
await api.post('/servers/deploy', config)
|
||||
} catch (e) {
|
||||
console.error('Failed to start deployment:', e)
|
||||
isDeploying.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function installOxide() {
|
||||
try {
|
||||
await api.post('/servers/install-oxide')
|
||||
} catch (e) {
|
||||
console.error('Failed to start Oxide installation:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeploymentStatus(status: DeploymentStatus) {
|
||||
deploymentStatus.value = status
|
||||
if (status.stage === 'online' || status.stage === 'failed') {
|
||||
isDeploying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearDeploymentStatus() {
|
||||
deploymentStatus.value = null
|
||||
isDeploying.value = false
|
||||
}
|
||||
|
||||
function updateStats(newStats: ServerStats) {
|
||||
stats.value = newStats
|
||||
}
|
||||
@@ -59,12 +94,18 @@ export const useServerStore = defineStore('server', () => {
|
||||
config,
|
||||
stats,
|
||||
isLoading,
|
||||
deploymentStatus,
|
||||
isDeploying,
|
||||
fetchServer,
|
||||
updateConfig,
|
||||
sendCommand,
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
deployServer,
|
||||
installOxide,
|
||||
updateDeploymentStatus,
|
||||
clearDeploymentStatus,
|
||||
updateStats,
|
||||
}
|
||||
})
|
||||
|
||||
145
frontend/src/stores/teleport.ts
Normal file
145
frontend/src/stores/teleport.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { TeleportConfigSummary, TeleportConfigFull, TeleportApplyResult } from '@/types'
|
||||
|
||||
export const useTeleportStore = defineStore('teleport', () => {
|
||||
const configs = ref<TeleportConfigSummary[]>([])
|
||||
const currentConfig = ref<TeleportConfigFull | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isApplying = ref(false)
|
||||
const isDirty = ref(false)
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
async function fetchConfigs() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.get<{ configs: TeleportConfigSummary[] }>('/teleport/configs')
|
||||
configs.value = res.configs
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig(id: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.get<{ config: TeleportConfigFull }>(`/teleport/configs/${id}`)
|
||||
currentConfig.value = res.config
|
||||
isDirty.value = false
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createConfig(name: string, description?: string) {
|
||||
try {
|
||||
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/configs', {
|
||||
config_name: name,
|
||||
description,
|
||||
})
|
||||
await fetchConfigs()
|
||||
currentConfig.value = res.config
|
||||
isDirty.value = false
|
||||
toast.success(`Config "${name}" created`)
|
||||
return res.config
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentConfig() {
|
||||
if (!currentConfig.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
await api.put(`/teleport/configs/${currentConfig.value.id}`, {
|
||||
config_name: currentConfig.value.config_name,
|
||||
description: currentConfig.value.description,
|
||||
config_data: currentConfig.value.config_data,
|
||||
})
|
||||
isDirty.value = false
|
||||
await fetchConfigs()
|
||||
toast.success('Config saved')
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConfig(id: string) {
|
||||
try {
|
||||
await api.del(`/teleport/configs/${id}`)
|
||||
if (currentConfig.value?.id === id) {
|
||||
currentConfig.value = null
|
||||
}
|
||||
await fetchConfigs()
|
||||
toast.success('Config deleted')
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyToServer(id: string) {
|
||||
isApplying.value = true
|
||||
try {
|
||||
const res = await api.post<TeleportApplyResult>(`/teleport/configs/${id}/apply`)
|
||||
await fetchConfigs()
|
||||
toast.success(res.message)
|
||||
return res
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
isApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromServer(configName: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/import-from-server', {
|
||||
config_name: configName,
|
||||
})
|
||||
await fetchConfigs()
|
||||
currentConfig.value = res.config
|
||||
isDirty.value = false
|
||||
toast.success(`Config imported from server as "${configName}"`)
|
||||
return res.config
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
configs,
|
||||
currentConfig,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isApplying,
|
||||
isDirty,
|
||||
fetchConfigs,
|
||||
loadConfig,
|
||||
createConfig,
|
||||
saveCurrentConfig,
|
||||
deleteConfig,
|
||||
applyToServer,
|
||||
importFromServer,
|
||||
markDirty,
|
||||
}
|
||||
})
|
||||
@@ -27,6 +27,7 @@ export interface AuthResponse {
|
||||
refresh_token: string
|
||||
requires_totp: boolean
|
||||
user: User
|
||||
license: License | null
|
||||
}
|
||||
|
||||
export interface ServerConnection {
|
||||
@@ -423,3 +424,125 @@ export interface StoreTransaction {
|
||||
payer_email: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Deployment types
|
||||
export interface DeploymentConfig {
|
||||
server_name: string
|
||||
max_players: number
|
||||
world_size: number
|
||||
seed: number
|
||||
server_port: number
|
||||
rcon_port: number
|
||||
rcon_password: string
|
||||
}
|
||||
|
||||
export interface DeploymentStatus {
|
||||
stage: 'downloading_steamcmd' | 'installing_steamcmd' | 'downloading_rust' | 'configuring' | 'starting' | 'online' | 'failed'
|
||||
progress: number
|
||||
message: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Loot Builder types — BetterLoot integration
|
||||
export interface LootProfileSummary {
|
||||
id: string
|
||||
profile_name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LootProfileFull {
|
||||
id: string
|
||||
license_id: string
|
||||
profile_name: string
|
||||
description: string | null
|
||||
loot_table: Record<string, PrefabLoot>
|
||||
loot_groups: Record<string, LootGroupProfile>
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PrefabLoot {
|
||||
Enabled: boolean
|
||||
LootProfiles: LootProfileRef[]
|
||||
GuaranteedItems: Record<string, LootItemSettings>
|
||||
UngroupedItems: Record<string, LootRNG>
|
||||
ItemSettings: {
|
||||
ItemsMin: number
|
||||
ItemsMax: number
|
||||
MinScrap: number
|
||||
MaxScrap: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface LootProfileRef {
|
||||
Enabled: boolean
|
||||
LootProfileName: string
|
||||
LootProfileProbability: number
|
||||
}
|
||||
|
||||
export interface LootItemSettings {
|
||||
Min: number
|
||||
Max: number
|
||||
SkinId: number
|
||||
DisplayName: string
|
||||
}
|
||||
|
||||
export interface LootEntry extends LootItemSettings {
|
||||
DurabilitySettings: {
|
||||
MinDurability: number
|
||||
MaxDurability: number
|
||||
}
|
||||
ItemEntryModifications: {
|
||||
AmmoSettings: Record<string, any> | null
|
||||
AttachmentSettings: Record<string, any> | null
|
||||
}
|
||||
BonusItems: Record<string, LootItemSettings>
|
||||
}
|
||||
|
||||
export interface LootRNG extends LootEntry {
|
||||
Probability: number
|
||||
}
|
||||
|
||||
export interface LootGroupProfile {
|
||||
Enabled: boolean
|
||||
GuaranteedItems: Record<string, LootItemSettings>
|
||||
ItemList: Record<string, LootRNG>
|
||||
}
|
||||
|
||||
export interface LootApplyResult {
|
||||
success: boolean
|
||||
message: string
|
||||
profile_name: string
|
||||
multiplier: number
|
||||
}
|
||||
|
||||
// Teleport Config types — NTeleportation integration
|
||||
export interface TeleportConfigSummary {
|
||||
id: string
|
||||
config_name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TeleportConfigFull {
|
||||
id: string
|
||||
license_id: string
|
||||
config_name: string
|
||||
description: string | null
|
||||
config_data: Record<string, any>
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TeleportApplyResult {
|
||||
success: boolean
|
||||
message: string
|
||||
config_name: string
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
|
||||
const loading = ref(true)
|
||||
@@ -45,8 +47,8 @@ const loadAnalytics = async () => {
|
||||
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error)
|
||||
} catch {
|
||||
toast.error('Failed to load analytics data')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -206,8 +208,8 @@ const downloadCSV = async () => {
|
||||
a.download = `server_stats_${timeRange.value}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download CSV:', error)
|
||||
} catch {
|
||||
toast.error('Failed to download analytics export')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +306,30 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Retention (Phase 2.2 placeholder) -->
|
||||
<!-- Player Retention -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Retention</h2>
|
||||
<div class="h-48 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
||||
<p class="text-sm text-neutral-600">Available in Phase 2.2 — New vs returning players, session duration</p>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ChatMessage } from '@/types'
|
||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
@@ -53,7 +55,7 @@ async function fetchMessages() {
|
||||
const data = await api.get<{ messages: ChatMessage[] }>('/chat')
|
||||
messages.value = data.messages
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load chat messages')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -64,7 +66,7 @@ async function toggleFlag(msg: ChatMessage) {
|
||||
await api.put(`/chat/${msg.id}/flag`, { flagged: !msg.flagged })
|
||||
msg.flagged = !msg.flagged
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to update flag')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const nextWipeDate = computed<string>(() => {
|
||||
|
||||
if (upcoming.length === 0) return 'Not Scheduled'
|
||||
|
||||
return upcoming[0].toLocaleDateString('en-US', {
|
||||
return upcoming[0]!.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
|
||||
49
frontend/src/views/admin/FileManagerView.vue
Normal file
49
frontend/src/views/admin/FileManagerView.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Recreate the RemoteDriver reactively so the token stays current across
|
||||
// automatic refresh cycles (useApi composable silently rotates accessToken).
|
||||
const driver = computed(
|
||||
() =>
|
||||
new RemoteDriver({
|
||||
baseURL: '/api/files',
|
||||
token: auth.accessToken ?? undefined,
|
||||
})
|
||||
)
|
||||
|
||||
// Non-persistent config passed to VueFinder per session.
|
||||
// maxFileSize in bytes — 10 MB limit matches the backend upload ceiling.
|
||||
const finderConfig = {
|
||||
theme: 'midnight',
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
showMenuBar: true,
|
||||
showToolbar: true,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
||||
style="min-height: 640px;"
|
||||
>
|
||||
<VueFinder
|
||||
id="corrosion-filemanager"
|
||||
:driver="driver"
|
||||
:config="finderConfig"
|
||||
locale="en"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
393
frontend/src/views/admin/LootBuilderView.vue
Normal file
393
frontend/src/views/admin/LootBuilderView.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useLootStore } from '@/stores/loot'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
||||
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
||||
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
||||
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
||||
import { Save, Upload, Download, Play, Copy, Trash2, Plus, Layers } from 'lucide-vue-next'
|
||||
|
||||
const loot = useLootStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showImportModal = ref(false)
|
||||
const showItemPicker = ref(false)
|
||||
const newProfileName = ref('')
|
||||
const newProfileDesc = ref('')
|
||||
const selectedMultiplier = ref(1)
|
||||
const showApplyDropdown = ref(false)
|
||||
const importJson = ref('')
|
||||
const importName = ref('')
|
||||
const activeTab = ref<'items' | 'groups'>('items')
|
||||
|
||||
const multipliers = [1, 2, 5, 10]
|
||||
|
||||
onMounted(async () => {
|
||||
await loot.fetchProfiles()
|
||||
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
||||
await loot.loadProfile(loot.profiles[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleCreateProfile() {
|
||||
if (!newProfileName.value.trim()) return
|
||||
const profile = await loot.createProfile(newProfileName.value.trim(), newProfileDesc.value.trim() || undefined)
|
||||
if (profile) {
|
||||
showCreateModal.value = false
|
||||
newProfileName.value = ''
|
||||
newProfileDesc.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProfile() {
|
||||
if (!loot.currentProfile) return
|
||||
if (!confirm(`Delete "${loot.currentProfile.profile_name}"?`)) return
|
||||
await loot.deleteProfile(loot.currentProfile.id)
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (!loot.currentProfile) return
|
||||
const dup = await loot.duplicateProfile(loot.currentProfile.id)
|
||||
if (dup) await loot.loadProfile(dup.id)
|
||||
}
|
||||
|
||||
async function handleApply(mult: number) {
|
||||
if (!loot.currentProfile) return
|
||||
showApplyDropdown.value = false
|
||||
if (loot.isDirty) {
|
||||
await loot.saveCurrentProfile()
|
||||
}
|
||||
await loot.applyToServer(loot.currentProfile.id, mult)
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!importName.value.trim() || !importJson.value.trim()) return
|
||||
try {
|
||||
const parsed = JSON.parse(importJson.value)
|
||||
// Support both full export format and raw LootTables format
|
||||
const lootTable = parsed.loot_table || parsed
|
||||
const lootGroups = parsed.loot_groups || {}
|
||||
await loot.importProfile(importName.value.trim(), lootTable, lootGroups)
|
||||
showImportModal.value = false
|
||||
importJson.value = ''
|
||||
importName.value = ''
|
||||
} catch {
|
||||
toast.error('Invalid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (!loot.currentProfile) return
|
||||
const data = await loot.exportProfile(loot.currentProfile.id, selectedMultiplier.value)
|
||||
if (!data) return
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${data.profile_name}_${data.multiplier}x.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleProfileChange(id: string) {
|
||||
if (loot.isDirty) {
|
||||
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||
}
|
||||
await loot.loadProfile(id)
|
||||
}
|
||||
|
||||
function handleAddItem(shortname: string) {
|
||||
if (!loot.currentProfile || !loot.selectedContainer) return
|
||||
const table = loot.currentProfile.loot_table
|
||||
if (!table[loot.selectedContainer]) {
|
||||
table[loot.selectedContainer] = {
|
||||
Enabled: true,
|
||||
LootProfiles: [],
|
||||
GuaranteedItems: {},
|
||||
UngroupedItems: {},
|
||||
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
|
||||
}
|
||||
}
|
||||
const container = table[loot.selectedContainer]!
|
||||
if (!container.UngroupedItems) container.UngroupedItems = {}
|
||||
if (!container.UngroupedItems[shortname]) {
|
||||
container.UngroupedItems[shortname] = {
|
||||
Min: 1,
|
||||
Max: 1,
|
||||
SkinId: 0,
|
||||
DisplayName: '',
|
||||
Probability: 50,
|
||||
DurabilitySettings: { MinDurability: 1, MaxDurability: 1 },
|
||||
ItemEntryModifications: { AmmoSettings: null, AttachmentSettings: null },
|
||||
BonusItems: {},
|
||||
}
|
||||
loot.markDirty()
|
||||
}
|
||||
showItemPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Loot Builder</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Bar -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Profile Selector -->
|
||||
<select
|
||||
v-if="loot.profiles.length > 0"
|
||||
:value="loot.currentProfile?.id || ''"
|
||||
@change="handleProfileChange(($event.target as HTMLSelectElement).value)"
|
||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||
>
|
||||
<option v-for="p in loot.profiles" :key="p.id" :value="p.id">
|
||||
{{ p.profile_name }}
|
||||
<template v-if="p.is_active"> (Active)</template>
|
||||
</option>
|
||||
</select>
|
||||
<span v-else class="text-neutral-500 text-sm">No profiles yet</span>
|
||||
|
||||
<!-- Save -->
|
||||
<button
|
||||
@click="loot.saveCurrentProfile()"
|
||||
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
{{ loot.isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<!-- Apply Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showApplyDropdown = !showApplyDropdown"
|
||||
:disabled="!loot.currentProfile || loot.isApplying"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
{{ loot.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||
</button>
|
||||
<div
|
||||
v-if="showApplyDropdown"
|
||||
class="absolute top-full mt-1 right-0 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
v-for="m in multipliers"
|
||||
:key="m"
|
||||
@click="handleApply(m)"
|
||||
class="w-full text-left px-4 py-2 text-sm text-neutral-300 hover:bg-neutral-700"
|
||||
>
|
||||
{{ m }}x Multiplier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate -->
|
||||
<button
|
||||
@click="handleDuplicate"
|
||||
:disabled="!loot.currentProfile"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
<!-- Import -->
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
|
||||
<!-- Export -->
|
||||
<button
|
||||
@click="handleExport"
|
||||
:disabled="!loot.currentProfile"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
@click="handleDeleteProfile"
|
||||
:disabled="!loot.currentProfile"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-if="loot.currentProfile" class="flex gap-4" style="height: calc(100vh - 250px)">
|
||||
<!-- Sidebar -->
|
||||
<LootContainerSidebar
|
||||
:loot-table="loot.currentProfile.loot_table"
|
||||
:selected="loot.selectedContainer"
|
||||
@select="loot.selectedContainer = $event"
|
||||
/>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-neutral-800 mb-4">
|
||||
<button
|
||||
@click="activeTab = 'items'"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'items' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||
>
|
||||
Container Items
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'groups'"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2"
|
||||
:class="activeTab === 'groups' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||
>
|
||||
<Layers class="w-4 h-4" />
|
||||
Loot Groups
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<LootItemEditor
|
||||
v-if="activeTab === 'items' && loot.selectedContainer"
|
||||
:container-key="loot.selectedContainer"
|
||||
:loot-table="loot.currentProfile.loot_table"
|
||||
@dirty="loot.markDirty()"
|
||||
@add-item="showItemPicker = true"
|
||||
/>
|
||||
<div v-else-if="activeTab === 'items'" class="flex items-center justify-center h-full text-neutral-500">
|
||||
Select a container from the sidebar
|
||||
</div>
|
||||
|
||||
<LootGroupEditor
|
||||
v-if="activeTab === 'groups'"
|
||||
:loot-groups="loot.currentProfile.loot_groups"
|
||||
@dirty="loot.markDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loot.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||
<Layers class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Loot Profile Selected</h2>
|
||||
<p class="text-neutral-500 mb-4">Create a new profile or select one from the dropdown above.</p>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||
>
|
||||
Create First Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loot.isLoading" class="flex items-center justify-center py-20">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Loot Profile</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
||||
<input
|
||||
v-model="newProfileName"
|
||||
placeholder="e.g. Vanilla 2x"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
@keydown.enter="handleCreateProfile"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
v-model="newProfileDesc"
|
||||
rows="2"
|
||||
placeholder="What is this profile for?"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||
<button
|
||||
@click="handleCreateProfile"
|
||||
:disabled="!newProfileName.trim()"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg">
|
||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import Loot Profile</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
||||
<input
|
||||
v-model="importName"
|
||||
placeholder="Name for imported profile"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">BetterLoot JSON</label>
|
||||
<textarea
|
||||
v-model="importJson"
|
||||
rows="10"
|
||||
placeholder="Paste LootTables.json content here..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||
<button
|
||||
@click="handleImport"
|
||||
:disabled="!importName.trim() || !importJson.trim()"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Picker Modal -->
|
||||
<LootItemPicker
|
||||
v-if="showItemPicker"
|
||||
@select="handleAddItem"
|
||||
@close="showItemPicker = false"
|
||||
/>
|
||||
|
||||
<!-- Click-away for apply dropdown -->
|
||||
<div v-if="showApplyDropdown" class="fixed inset-0 z-0" @click="showApplyDropdown = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,7 +30,7 @@ async function fetchMaps() {
|
||||
const data = await api.get<{ maps: MapEntry[] }>('/maps')
|
||||
maps.value = data.maps
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load map library')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { NotificationConfig } from '@/types'
|
||||
import { Bell, Save, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const config = ref<NotificationConfig>({
|
||||
discord_webhook_url: null,
|
||||
@@ -38,7 +40,7 @@ async function fetchConfig() {
|
||||
const data = await api.get<{ config: NotificationConfig }>('/notifications/config')
|
||||
config.value = data.config
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load notification settings')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -48,8 +50,9 @@ async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put('/notifications/config', config.value)
|
||||
toast.success('Notification settings saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save notification settings')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const server = useServerStore()
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
interface Player {
|
||||
steam_id: string
|
||||
@@ -70,7 +72,7 @@ async function fetchPlayers() {
|
||||
const data = await api.get<{ players: Player[] }>('/players')
|
||||
players.value = data.players
|
||||
} catch {
|
||||
// API not wired yet — will show empty state
|
||||
toast.error('Failed to load player list')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -80,9 +82,10 @@ async function kickPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Kick ${name}?`)) return
|
||||
try {
|
||||
await server.sendCommand(`kick ${steamId}`)
|
||||
toast.success(`Kick command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to kick ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +93,10 @@ async function banPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Ban ${name}? This will also kick them.`)) return
|
||||
try {
|
||||
await server.sendCommand(`ban ${steamId}`)
|
||||
toast.success(`Ban command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ban ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugins'
|
||||
import type { UmodPlugin } from '@/stores/plugins'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { PluginEntry } from '@/types'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const tab = ref<'installed' | 'browse'>('installed')
|
||||
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
||||
const browseQuery = ref('')
|
||||
const browsePage = ref(1)
|
||||
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const installing = ref<string | null>(null)
|
||||
|
||||
// Upload state
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const isDragOver = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
let result = pluginStore.plugins
|
||||
@@ -20,6 +31,8 @@ const filteredPlugins = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
||||
|
||||
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
@@ -59,6 +72,96 @@ async function handleUninstall(plugin: PluginEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseSearch(page = 1) {
|
||||
if (!browseQuery.value.trim()) return
|
||||
browsePage.value = page
|
||||
try {
|
||||
await pluginStore.browseUmod(browseQuery.value.trim(), page)
|
||||
} catch {
|
||||
toast.error('Failed to search uMod plugins')
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBrowseSearch() {
|
||||
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
||||
browseDebounce.value = setTimeout(() => handleBrowseSearch(1), 400)
|
||||
}
|
||||
|
||||
function browsePrev() {
|
||||
if (browsePage.value > 1) handleBrowseSearch(browsePage.value - 1)
|
||||
}
|
||||
|
||||
function browseNext() {
|
||||
if (pluginStore.browseResults && browsePage.value < pluginStore.browseResults.last_page) {
|
||||
handleBrowseSearch(browsePage.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function installFromBrowse(result: UmodPlugin) {
|
||||
installing.value = result.name
|
||||
try {
|
||||
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
|
||||
toast.success(`${result.name} installed`)
|
||||
} catch {
|
||||
toast.error(`Failed to install ${result.name}`)
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Upload helpers
|
||||
function isAlreadyInstalled(name: string): boolean {
|
||||
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
|
||||
}
|
||||
|
||||
function validateCsFile(file: File): string | null {
|
||||
if (!file.name.toLowerCase().endsWith('.cs')) {
|
||||
return 'Only .cs plugin files are accepted'
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return 'File must be under 5 MB'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleFilePick(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const err = validateCsFile(file)
|
||||
if (err) { toast.error(err); return }
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (!file) return
|
||||
const err = validateCsFile(file)
|
||||
if (err) { toast.error(err); return }
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
function clearUpload() {
|
||||
uploadFile.value = null
|
||||
if (uploadInput.value) uploadInput.value.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadFile.value) return
|
||||
isUploading.value = true
|
||||
try {
|
||||
await pluginStore.uploadPlugin(uploadFile.value)
|
||||
toast.success(`${uploadFile.value.name} uploaded successfully`)
|
||||
clearUpload()
|
||||
tab.value = 'installed'
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Upload failed')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pluginStore.fetchPlugins()
|
||||
})
|
||||
@@ -104,13 +207,31 @@ onMounted(() => {
|
||||
>
|
||||
Browse uMod
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'upload'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Upload Custom
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="tab === 'installed' ? 'Search installed plugins...' : 'Search uMod...'"
|
||||
placeholder="Search installed plugins..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="browseQuery"
|
||||
type="text"
|
||||
placeholder="Search uMod plugins..."
|
||||
@input="scheduleBrowseSearch"
|
||||
@keydown.enter="handleBrowseSearch(1)"
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -187,11 +308,202 @@ onMounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Browse uMod (placeholder) -->
|
||||
<div v-if="tab === 'browse'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">uMod Plugin Browser</h3>
|
||||
<p class="text-sm text-neutral-500">Search and install plugins directly from uMod. Coming soon.</p>
|
||||
<!-- Browse uMod -->
|
||||
<div v-if="tab === 'browse'">
|
||||
<!-- Empty state: no search yet -->
|
||||
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
||||
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
||||
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
||||
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
||||
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="browsePrev"
|
||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
@click="browseNext"
|
||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr
|
||||
v-for="result in browsePlugins"
|
||||
:key="result.name"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
|
||||
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
@click="installFromBrowse(result)"
|
||||
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
||||
:class="isAlreadyInstalled(result.name)
|
||||
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
||||
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
||||
>
|
||||
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Download v-else class="w-3.5 h-3.5" />
|
||||
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Bottom pagination -->
|
||||
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
|
||||
<p class="text-xs text-neutral-500">
|
||||
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="browsePrev"
|
||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
@click="browseNext"
|
||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Custom Plugin -->
|
||||
<div v-if="tab === 'upload'" class="space-y-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
|
||||
<p class="text-sm text-neutral-500 mb-6">
|
||||
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
|
||||
The file will be pushed to your server via the companion agent.
|
||||
</p>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
|
||||
:class="isDragOver
|
||||
? 'border-oxide-500 bg-oxide-500/5'
|
||||
: uploadFile
|
||||
? 'border-green-600 bg-green-900/10'
|
||||
: 'border-neutral-700 hover:border-neutral-600'"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="uploadInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept=".cs"
|
||||
class="hidden"
|
||||
@change="handleFilePick"
|
||||
/>
|
||||
|
||||
<!-- No file selected -->
|
||||
<template v-if="!uploadFile">
|
||||
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
|
||||
</template>
|
||||
|
||||
<!-- File selected -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="clearUpload"
|
||||
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="handleUpload"
|
||||
:disabled="!uploadFile || isUploading"
|
||||
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="uploadFile"
|
||||
@click="clearUpload"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info card -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
||||
<span class="font-medium text-neutral-400">Note:</span>
|
||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import {
|
||||
Server,
|
||||
Wifi,
|
||||
@@ -14,15 +15,38 @@ import {
|
||||
Download,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Rocket,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Puzzle,
|
||||
} from 'lucide-vue-next'
|
||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
const server = useServerStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const editMode = ref(false)
|
||||
const saving = ref(false)
|
||||
const actionLoading = ref<string | null>(null)
|
||||
const copied = ref(false)
|
||||
const setupTab = ref<'linux' | 'windows'>('linux')
|
||||
const windowsCopied = ref(false)
|
||||
const showDeployForm = ref(false)
|
||||
const deployLoading = ref(false)
|
||||
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
|
||||
const isInstallingOxide = ref(false)
|
||||
|
||||
const deployForm = ref<DeploymentConfig>({
|
||||
server_name: 'My Rust Server',
|
||||
max_players: 100,
|
||||
world_size: 4000,
|
||||
seed: Math.floor(Math.random() * 2147483647),
|
||||
server_port: 28015,
|
||||
rcon_port: 28016,
|
||||
rcon_password: '',
|
||||
})
|
||||
|
||||
const isAgentConnected = computed(() =>
|
||||
server.connection?.connection_type === 'bare_metal' &&
|
||||
@@ -48,26 +72,114 @@ const agentLastSeenLabel = computed(() => {
|
||||
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
|
||||
|
||||
const linuxCommands = computed(() => `# Download the agent
|
||||
curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64
|
||||
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
|
||||
chmod +x corrosion-companion-linux-amd64
|
||||
|
||||
# Start with your license key
|
||||
export LICENSE_ID="${licenseKey.value}"
|
||||
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||
export NATS_TOKEN="<your-nats-token>"
|
||||
export GAME_SERVER_PATH="/path/to/RustDedicated"
|
||||
./corrosion-companion-linux-amd64`)
|
||||
|
||||
async function copyCommands() {
|
||||
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
||||
# Download the agent
|
||||
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe" -OutFile "corrosion-companion-windows-amd64.exe"
|
||||
|
||||
# Start with your license key
|
||||
$env:LICENSE_ID="${licenseKey.value}"
|
||||
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||
.\\corrosion-companion-windows-amd64.exe`)
|
||||
|
||||
async function copySetupCommands() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(linuxCommands.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
const text = setupTab.value === 'linux' ? linuxCommands.value : windowsCommands.value
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (setupTab.value === 'linux') {
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} else {
|
||||
windowsCopied.value = true
|
||||
setTimeout(() => { windowsCopied.value = false }, 2000)
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API unavailable
|
||||
}
|
||||
}
|
||||
|
||||
async function startDeploy() {
|
||||
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
|
||||
deployLoading.value = true
|
||||
try {
|
||||
await server.deployServer(deployForm.value)
|
||||
showDeployForm.value = false
|
||||
} catch {
|
||||
toast.error('Failed to start deployment')
|
||||
} finally {
|
||||
deployLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deployStages = [
|
||||
{ key: 'downloading_steamcmd', label: 'Download SteamCMD' },
|
||||
{ key: 'installing_steamcmd', label: 'Install SteamCMD' },
|
||||
{ key: 'downloading_rust', label: 'Download Rust Server' },
|
||||
{ key: 'configuring', label: 'Configure' },
|
||||
{ key: 'starting', label: 'Start Server' },
|
||||
{ key: 'online', label: 'Online' },
|
||||
] as const
|
||||
|
||||
function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
|
||||
const status = server.deploymentStatus
|
||||
if (!status) return 'pending'
|
||||
if (status.stage === 'failed') {
|
||||
const idx = deployStages.findIndex(s => s.key === stageKey)
|
||||
const failIdx = deployStages.findIndex(s => s.key === status.stage)
|
||||
if (idx < failIdx) return 'complete'
|
||||
if (idx === failIdx) return 'failed'
|
||||
return 'pending'
|
||||
}
|
||||
const currentIdx = deployStages.findIndex(s => s.key === status.stage)
|
||||
const thisIdx = deployStages.findIndex(s => s.key === stageKey)
|
||||
if (thisIdx < currentIdx) return 'complete'
|
||||
if (thisIdx === currentIdx) return status.stage === 'online' ? 'complete' : 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const oxideStages = [
|
||||
{ key: 'fetching_release', label: 'Check Latest Release' },
|
||||
{ key: 'downloading', label: 'Download Oxide' },
|
||||
{ key: 'installing', label: 'Extract Files' },
|
||||
{ key: 'restarting', label: 'Restart Server' },
|
||||
{ key: 'complete', label: 'Complete' },
|
||||
] as const
|
||||
|
||||
function getOxideStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
|
||||
if (!oxideStatus.value) return 'pending'
|
||||
const status = oxideStatus.value
|
||||
if (status.stage === 'failed') {
|
||||
const currentStages = oxideStages
|
||||
const idx = currentStages.findIndex(s => s.key === stageKey)
|
||||
// Find which stage was active when failure occurred — approximate from message
|
||||
// For failed state, mark all stages before current as complete
|
||||
return idx === 0 ? 'failed' : 'pending'
|
||||
}
|
||||
const currentIdx = oxideStages.findIndex(s => s.key === status.stage)
|
||||
const thisIdx = oxideStages.findIndex(s => s.key === stageKey)
|
||||
if (thisIdx < currentIdx) return 'complete'
|
||||
if (thisIdx === currentIdx) return status.stage === 'complete' ? 'complete' : 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
async function installOxide() {
|
||||
isInstallingOxide.value = true
|
||||
oxideStatus.value = null
|
||||
try {
|
||||
await server.installOxide()
|
||||
} catch {
|
||||
toast.error('Failed to start Oxide installation')
|
||||
isInstallingOxide.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
server_name: '',
|
||||
max_players: 0,
|
||||
@@ -91,8 +203,9 @@ async function saveConfig() {
|
||||
try {
|
||||
await server.updateConfig(form.value)
|
||||
editMode.value = false
|
||||
toast.success('Server configuration saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save server configuration')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -105,16 +218,41 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||
else if (action === 'stop') await server.stopServer()
|
||||
else await server.restartServer()
|
||||
await server.fetchServer()
|
||||
toast.success(`Server ${action} command sent`)
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ${action} server`)
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
||||
if (!server.config) return
|
||||
const newValue = !server.config[field]
|
||||
try {
|
||||
await server.updateConfig({ [field]: newValue })
|
||||
toast.success('Automation setting saved')
|
||||
} catch {
|
||||
toast.error('Failed to save automation setting')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await server.fetchServer()
|
||||
loadFormFromConfig()
|
||||
|
||||
const ws = useWebSocket()
|
||||
ws.subscribe((msg) => {
|
||||
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
||||
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
||||
}
|
||||
if (msg.type === 'event' && msg.event === 'oxide_status') {
|
||||
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
|
||||
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
|
||||
isInstallingOxide.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -244,7 +382,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64"
|
||||
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
|
||||
download="corrosion-companion-linux-amd64"
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
@@ -252,7 +390,7 @@ onMounted(async () => {
|
||||
Linux (amd64)
|
||||
</a>
|
||||
<a
|
||||
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-windows-amd64.exe"
|
||||
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
|
||||
download="corrosion-companion-windows-amd64.exe"
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
@@ -262,34 +400,268 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Setup Section -->
|
||||
<!-- Quick Setup Section — Tabbed -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="w-3.5 h-3.5 text-neutral-500" />
|
||||
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup (Linux)</p>
|
||||
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyCommands"
|
||||
@click="copySetupCommands"
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
|
||||
:class="copied
|
||||
:class="(setupTab === 'linux' ? copied : windowsCopied)
|
||||
? 'bg-green-600/20 text-green-400 border border-green-600/30'
|
||||
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-neutral-200 border border-neutral-700'"
|
||||
>
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
||||
|
||||
<!-- OS Tabs -->
|
||||
<div class="flex bg-neutral-800 rounded-md p-0.5 mb-3 w-fit">
|
||||
<button
|
||||
@click="setupTab = 'linux'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'linux' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Linux</button>
|
||||
<button
|
||||
@click="setupTab = 'windows'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'windows' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Windows</button>
|
||||
</div>
|
||||
|
||||
<!-- Windows Warning Badge -->
|
||||
<div v-if="setupTab === 'windows'" class="flex items-center gap-2 mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
||||
<AlertTriangle class="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<p class="text-xs text-amber-300">PowerShell Required — Command Prompt is not supported</p>
|
||||
</div>
|
||||
|
||||
<!-- Linux Commands -->
|
||||
<div v-if="setupTab === 'linux'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
||||
<p class="text-neutral-500"># Download the agent</p>
|
||||
<p>curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64</p>
|
||||
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
|
||||
<p>chmod +x corrosion-companion-linux-amd64</p>
|
||||
<p class="mt-3 text-neutral-500"># Start with your license key</p>
|
||||
<p>export LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
|
||||
<p>export NATS_URL=<span class="text-oxide-400">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||
<p>export NATS_TOKEN=<span class="text-neutral-500">"<your-nats-token>"</span></p>
|
||||
<p>export GAME_SERVER_PATH=<span class="text-neutral-500">"/path/to/RustDedicated"</span></p>
|
||||
<p>./corrosion-companion-linux-amd64</p>
|
||||
</div>
|
||||
|
||||
<!-- Windows Commands -->
|
||||
<div v-if="setupTab === 'windows'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
||||
<p class="text-neutral-500"># Requires PowerShell (not Command Prompt)</p>
|
||||
<p class="text-neutral-500"># Download the agent</p>
|
||||
<p>Invoke-WebRequest -Uri <span class="text-oxide-400">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="text-oxide-400">"corrosion-companion-windows-amd64.exe"</span></p>
|
||||
<p class="mt-3 text-neutral-500"># Start with your license key</p>
|
||||
<p>$env:LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
|
||||
<p>$env:NATS_URL=<span class="text-oxide-400">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||
<p>.\corrosion-companion-windows-amd64.exe</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Rust Server -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<Rocket class="w-4 h-4 text-oxide-400" />
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Deploy Rust Server</h2>
|
||||
</div>
|
||||
|
||||
<!-- Deployment Progress Tracker -->
|
||||
<div v-if="server.deploymentStatus || server.isDeploying" class="mb-6">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="stage in deployStages"
|
||||
:key="stage.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<!-- Stage indicator -->
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="{
|
||||
'bg-neutral-800 text-neutral-600': getStageState(stage.key) === 'pending',
|
||||
'bg-amber-500/20 text-amber-400': getStageState(stage.key) === 'active',
|
||||
'bg-green-500/20 text-green-400': getStageState(stage.key) === 'complete',
|
||||
'bg-red-500/20 text-red-400': getStageState(stage.key) === 'failed',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="getStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Check v-else-if="getStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
|
||||
<AlertTriangle v-else-if="getStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
|
||||
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
<!-- Stage label -->
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="{
|
||||
'text-neutral-600': getStageState(stage.key) === 'pending',
|
||||
'text-amber-300 font-medium': getStageState(stage.key) === 'active',
|
||||
'text-green-400': getStageState(stage.key) === 'complete',
|
||||
'text-red-400': getStageState(stage.key) === 'failed',
|
||||
}"
|
||||
>{{ stage.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div v-if="server.deploymentStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
|
||||
<p class="text-xs text-neutral-400">{{ server.deploymentStatus.message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="server.deploymentStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p class="text-xs text-red-400">{{ server.deploymentStatus.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Retry button on failure -->
|
||||
<button
|
||||
v-if="server.deploymentStatus?.stage === 'failed'"
|
||||
@click="server.clearDeploymentStatus(); showDeployForm = true"
|
||||
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
Retry Deployment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Form (shown when not deploying) -->
|
||||
<div v-else>
|
||||
<div v-if="!showDeployForm" class="text-center py-4">
|
||||
<p class="text-sm text-neutral-400 mb-4">Automatically install SteamCMD, download Rust Dedicated Server, configure, and start — all with one click.</p>
|
||||
<button
|
||||
@click="showDeployForm = true"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Rocket class="w-4 h-4" />
|
||||
Deploy Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="startDeploy" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-neutral-500 mb-1">Server Name</label>
|
||||
<input v-model="deployForm.server_name" type="text" required class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Max Players</label>
|
||||
<input v-model.number="deployForm.max_players" type="number" min="1" max="500" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">World Size</label>
|
||||
<input v-model.number="deployForm.world_size" type="number" min="1000" max="8000" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Map Seed</label>
|
||||
<input v-model.number="deployForm.seed" type="number" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Server Port</label>
|
||||
<input v-model.number="deployForm.server_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">RCON Port</label>
|
||||
<input v-model.number="deployForm.rcon_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-neutral-500 mb-1">RCON Password <span class="text-red-400">*</span></label>
|
||||
<input v-model="deployForm.rcon_password" type="password" required minlength="6" placeholder="Minimum 6 characters" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors placeholder:text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="deployLoading || !deployForm.rcon_password || deployForm.rcon_password.length < 6"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="deployLoading" class="w-4 h-4 animate-spin" />
|
||||
<Rocket v-else class="w-4 h-4" />
|
||||
{{ deployLoading ? 'Deploying...' : 'Deploy Server' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="showDeployForm = false"
|
||||
class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install Oxide/uMod -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<Puzzle class="w-4 h-4 text-oxide-400" />
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Install Oxide / uMod</h2>
|
||||
</div>
|
||||
|
||||
<!-- Installation Progress Tracker -->
|
||||
<div v-if="oxideStatus || isInstallingOxide" class="mb-6">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="stage in oxideStages"
|
||||
:key="stage.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<!-- Stage indicator -->
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="{
|
||||
'bg-neutral-800 text-neutral-600': getOxideStageState(stage.key) === 'pending',
|
||||
'bg-amber-500/20 text-amber-400': getOxideStageState(stage.key) === 'active',
|
||||
'bg-green-500/20 text-green-400': getOxideStageState(stage.key) === 'complete',
|
||||
'bg-red-500/20 text-red-400': getOxideStageState(stage.key) === 'failed',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="getOxideStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Check v-else-if="getOxideStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
|
||||
<AlertTriangle v-else-if="getOxideStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
|
||||
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
<!-- Stage label -->
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="{
|
||||
'text-neutral-600': getOxideStageState(stage.key) === 'pending',
|
||||
'text-amber-300 font-medium': getOxideStageState(stage.key) === 'active',
|
||||
'text-green-400': getOxideStageState(stage.key) === 'complete',
|
||||
'text-red-400': getOxideStageState(stage.key) === 'failed',
|
||||
}"
|
||||
>{{ stage.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div v-if="oxideStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
|
||||
<p class="text-xs text-neutral-400">{{ oxideStatus.message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="oxideStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p class="text-xs text-red-400">{{ oxideStatus.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Retry button on failure -->
|
||||
<button
|
||||
v-if="oxideStatus?.stage === 'failed'"
|
||||
@click="oxideStatus = null; installOxide()"
|
||||
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
Retry Installation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Install Button (shown when not installing) -->
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-sm text-neutral-400 mb-4">Install or update Oxide/uMod. Required for all plugins including CorrosionCompanion.</p>
|
||||
<button
|
||||
@click="installOxide()"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Puzzle class="w-4 h-4" />
|
||||
Install Oxide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -395,45 +767,51 @@ onMounted(async () => {
|
||||
<p class="text-sm text-neutral-200">Auto-Restart</p>
|
||||
<p class="text-xs text-neutral-500">Restart on crash detection</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('crash_recovery_enabled')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.crash_recovery_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.crash_recovery_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Auto-Update on Force Wipe</p>
|
||||
<p class="text-xs text-neutral-500">Update when Facepunch pushes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('auto_update_on_force_wipe')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Force Wipe Eligible</p>
|
||||
<p class="text-xs text-neutral-500">Server participates in force wipes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('force_wipe_eligible')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.force_wipe_eligible ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.force_wipe_eligible ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { TeamMember, Role } from '@/types'
|
||||
import { UserPlus, Shield, Mail, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const members = ref<TeamMember[]>([])
|
||||
const roles = ref<Role[]>([])
|
||||
@@ -30,7 +32,7 @@ async function fetchTeam() {
|
||||
members.value = data.members ?? []
|
||||
roles.value = data.roles ?? []
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load team members')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -47,9 +49,10 @@ async function sendInvite() {
|
||||
inviteEmail.value = ''
|
||||
inviteRole.value = ''
|
||||
showInvite.value = false
|
||||
toast.success('Invitation sent')
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to send invitation')
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
@@ -59,9 +62,10 @@ async function removeMember(member: TeamMember) {
|
||||
if (!confirm(`Remove ${member.username} from the team?`)) return
|
||||
try {
|
||||
await api.del(`/team/${member.id}`)
|
||||
toast.success(`${member.username} removed from team`)
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to remove ${member.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
694
frontend/src/views/admin/TeleportConfigView.vue
Normal file
694
frontend/src/views/admin/TeleportConfigView.vue
Normal file
@@ -0,0 +1,694 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTeleportStore } from '@/stores/teleport'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import PermissionGroupEditor from '@/components/teleport/PermissionGroupEditor.vue'
|
||||
import {
|
||||
Save,
|
||||
Play,
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
Navigation2,
|
||||
Home,
|
||||
Users,
|
||||
Settings as SettingsIcon,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const store = useTeleportStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const activeTab = ref<'general' | 'homes' | 'tpr' | 'vip'>('general')
|
||||
const showCreateModal = ref(false)
|
||||
const showImportModal = ref(false)
|
||||
const newConfigName = ref('')
|
||||
const newConfigDesc = ref('')
|
||||
const importConfigName = ref('')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general', label: 'General', icon: SettingsIcon },
|
||||
{ key: 'homes', label: 'Homes', icon: Home },
|
||||
{ key: 'tpr', label: 'TPR', icon: Navigation2 },
|
||||
{ key: 'vip', label: 'VIP Groups', icon: Users },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchConfigs()
|
||||
if (store.configs.length > 0 && store.configs[0]) {
|
||||
await store.loadConfig(store.configs[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Config data helpers ---
|
||||
|
||||
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||
if (!store.currentConfig?.config_data) return defaultValue
|
||||
const parts = path.split('.')
|
||||
let current: any = store.currentConfig.config_data
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return defaultValue
|
||||
current = current[part]
|
||||
}
|
||||
return current ?? defaultValue
|
||||
}
|
||||
|
||||
function setConfigValue(path: string, value: any) {
|
||||
if (!store.currentConfig) return
|
||||
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||
const parts = path.split('.')
|
||||
let current: any = store.currentConfig.config_data
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!
|
||||
if (current[part] == null || typeof current[part] !== 'object') {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value
|
||||
store.markDirty()
|
||||
}
|
||||
|
||||
// --- Action handlers ---
|
||||
|
||||
async function handleConfigChange(id: string) {
|
||||
if (store.isDirty) {
|
||||
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||
}
|
||||
await store.loadConfig(id)
|
||||
}
|
||||
|
||||
async function handleCreateConfig() {
|
||||
if (!newConfigName.value.trim()) return
|
||||
const config = await store.createConfig(
|
||||
newConfigName.value.trim(),
|
||||
newConfigDesc.value.trim() || undefined,
|
||||
)
|
||||
if (config) {
|
||||
showCreateModal.value = false
|
||||
newConfigName.value = ''
|
||||
newConfigDesc.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfig() {
|
||||
if (!store.currentConfig) return
|
||||
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||
await store.deleteConfig(store.currentConfig.id)
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!store.currentConfig) return
|
||||
if (!confirm('Apply this teleport config to the server? This will overwrite the current NTeleportation config.')) return
|
||||
if (store.isDirty) {
|
||||
await store.saveCurrentConfig()
|
||||
}
|
||||
await store.applyToServer(store.currentConfig.id)
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!importConfigName.value.trim()) return
|
||||
const config = await store.importFromServer(importConfigName.value.trim())
|
||||
if (config) {
|
||||
showImportModal.value = false
|
||||
importConfigName.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionGroupUpdate(updatedData: Record<string, any>) {
|
||||
if (!store.currentConfig) return
|
||||
store.currentConfig.config_data = updatedData
|
||||
store.markDirty()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-white">Teleport Config</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Selector + Action Bar -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Config Selector -->
|
||||
<select
|
||||
v-if="store.configs.length > 0"
|
||||
:value="store.currentConfig?.id || ''"
|
||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||
>
|
||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||
{{ c.config_name }}
|
||||
<template v-if="c.is_active"> (Active)</template>
|
||||
</option>
|
||||
</select>
|
||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||
|
||||
<!-- Save -->
|
||||
<button
|
||||
@click="store.saveCurrentConfig()"
|
||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<!-- Apply to Server -->
|
||||
<button
|
||||
@click="handleApply"
|
||||
:disabled="!store.currentConfig || store.isApplying"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||
</button>
|
||||
|
||||
<!-- Import from Server -->
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
Import from Server
|
||||
</button>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
@click="handleDeleteConfig"
|
||||
:disabled="!store.currentConfig"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
|
||||
<!-- No Config Selected -->
|
||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||
<Navigation2 class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Teleport Config Selected</h2>
|
||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||
>
|
||||
Create First Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Config Editor -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-neutral-800">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key as typeof activeTab"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-oxide-500 text-oxide-400'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- General Tab -->
|
||||
<div v-if="activeTab === 'general'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- UseEconomics -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Use Economics</label>
|
||||
<p class="text-xs text-neutral-500">Charge players for teleports via Economics plugin</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.UseEconomics', !getConfigValue('Settings.UseEconomics', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.UseEconomics', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.UseEconomics', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- UseServerRewards -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Use Server Rewards</label>
|
||||
<p class="text-xs text-neutral-500">Charge players via ServerRewards plugin</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.UseServerRewards', !getConfigValue('Settings.UseServerRewards', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.UseServerRewards', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.UseServerRewards', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CheckBoundaries -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Cave/Water boundary checks</label>
|
||||
<p class="text-xs text-neutral-500">Prevent teleporting into caves or underwater</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.CheckBoundaries', !getConfigValue('Settings.CheckBoundaries', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- InterruptTPOnHostile -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Cancel TP if hostile timer</label>
|
||||
<p class="text-xs text-neutral-500">Cancel pending teleport if player becomes hostile</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.InterruptTPOnHostile', !getConfigValue('Settings.InterruptTPOnHostile', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WipeHomesOnUpgrade -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Wipe homes on map update</label>
|
||||
<p class="text-xs text-neutral-500">Clear all home locations when the map changes</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.WipeHomesOnUpgrade', !getConfigValue('Settings.WipeHomesOnUpgrade', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PlayersOnlyCannotTeleport -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Players Only Cannot Teleport</label>
|
||||
<p class="text-xs text-neutral-500">Restrict teleport to specific player groups only</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Settings.PlayersOnlyCannotTeleport', !getConfigValue('Settings.PlayersOnlyCannotTeleport', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Cooldown (number) -->
|
||||
<div class="max-w-sm">
|
||||
<label class="block text-sm text-neutral-200 mb-1">Global cooldown (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Minimum time between any teleport commands</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('Settings.GlobalTeleportCooldown', 0)"
|
||||
@input="setConfigValue('Settings.GlobalTeleportCooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Homes Tab -->
|
||||
<div v-else-if="activeTab === 'homes'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Home Teleport Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- UsableOutOfBuildingBlocked -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Can use outside building privilege</label>
|
||||
<p class="text-xs text-neutral-500">Allow home teleport even without building privilege</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Home.UsableOutOfBuildingBlocked', !getConfigValue('Home.UsableOutOfBuildingBlocked', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ForceOnTopOfFoundation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Force home on foundation</label>
|
||||
<p class="text-xs text-neutral-500">Homes can only be set on a foundation block</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Home.ForceOnTopOfFoundation', !getConfigValue('Home.ForceOnTopOfFoundation', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CheckFoundationForOwner -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Verify foundation ownership</label>
|
||||
<p class="text-xs text-neutral-500">Only allow homes on foundations the player owns</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Home.CheckFoundationForOwner', !getConfigValue('Home.CheckFoundationForOwner', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AllowAboveFoundation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Allow Above Foundation</label>
|
||||
<p class="text-xs text-neutral-500">Allow setting homes above foundation level</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Home.AllowAboveFoundation', !getConfigValue('Home.AllowAboveFoundation', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CupOwnerAllowOnBuildingBlocked -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Cupboard Owner Allow on Building Blocked</label>
|
||||
<p class="text-xs text-neutral-500">Allow TC owners to teleport even when building blocked</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('Home.CupOwnerAllowOnBuildingBlocked', !getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Number Inputs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Homes Limit</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Default max homes per player</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('Home.HomesLimit', 3)"
|
||||
@input="setConfigValue('Home.HomesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Max home teleports per day</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('Home.DefaultDailyLimit', 5)"
|
||||
@input="setConfigValue('Home.DefaultDailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Time between home teleports</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('Home.DefaultCooldown', 600)"
|
||||
@input="setConfigValue('Home.DefaultCooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('Home.DefaultCountdown', 5)"
|
||||
@input="setConfigValue('Home.DefaultCountdown', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TPR Tab -->
|
||||
<div v-else-if="activeTab === 'tpr'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Teleport Request Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- BlockTPAOnCeiling -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Block TP accept on ceiling</label>
|
||||
<p class="text-xs text-neutral-500">Prevent accepting a TP while on a ceiling tile</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('TPR.BlockTPAOnCeiling', !getConfigValue('TPR.BlockTPAOnCeiling', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OffsetTPRTarget -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Offset teleport target position</label>
|
||||
<p class="text-xs text-neutral-500">Slightly offset the teleport landing position</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('TPR.OffsetTPRTarget', !getConfigValue('TPR.OffsetTPRTarget', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AutoAcceptEnabled -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-neutral-200">Auto Accept Enabled</label>
|
||||
<p class="text-xs text-neutral-500">Automatically accept incoming TP requests</p>
|
||||
</div>
|
||||
<button
|
||||
@click="setConfigValue('TPR.AutoAcceptEnabled', !getConfigValue('TPR.AutoAcceptEnabled', false))"
|
||||
class="relative w-11 h-6 rounded-full transition-colors"
|
||||
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Number Inputs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Cooldown between TPR requests</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('TPR.Cooldown', 600)"
|
||||
@input="setConfigValue('TPR.Cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('TPR.Countdown', 5)"
|
||||
@input="setConfigValue('TPR.Countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">Max TPR per day</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('TPR.DailyLimit', 5)"
|
||||
@input="setConfigValue('TPR.DailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-200 mb-1">Request Duration (seconds)</label>
|
||||
<p class="text-xs text-neutral-500 mb-2">How long a TPR request lasts</p>
|
||||
<input
|
||||
type="number"
|
||||
:value="getConfigValue('TPR.RequestDuration', 30)"
|
||||
@input="setConfigValue('TPR.RequestDuration', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIP Groups Tab -->
|
||||
<div v-else-if="activeTab === 'vip'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||
<PermissionGroupEditor
|
||||
:config-data="store.currentConfig.config_data"
|
||||
@update:config-data="handlePermissionGroupUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Config Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Teleport Config</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||
<input
|
||||
v-model="newConfigName"
|
||||
placeholder="e.g. Default TP Settings"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
@keydown.enter="handleCreateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
v-model="newConfigDesc"
|
||||
rows="2"
|
||||
placeholder="What is this config for?"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||
<button
|
||||
@click="handleCreateConfig"
|
||||
:disabled="!newConfigName.trim()"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import from Server Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||
<p class="text-sm text-neutral-400 mb-4">
|
||||
Import the current NTeleportation config from your live server. This will create a new config profile.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||
<input
|
||||
v-model="importConfigName"
|
||||
placeholder="e.g. Imported Server Config"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||
@keydown.enter="handleImport"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||
<button
|
||||
@click="handleImport"
|
||||
:disabled="!importConfigName.trim()"
|
||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,18 +3,29 @@ import { ref, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
const toast = useToastStore()
|
||||
const api = useApi()
|
||||
|
||||
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
||||
const selectedProfileId = ref<string>('')
|
||||
const triggerLoading = ref(false)
|
||||
const dryRunLoading = ref(false)
|
||||
const scheduleToggling = ref<string | null>(null)
|
||||
|
||||
interface DryRunResult {
|
||||
would_delete: string[]
|
||||
would_preserve: string[]
|
||||
estimated_duration_seconds: number
|
||||
}
|
||||
|
||||
const dryRunResult = ref<DryRunResult | null>(null)
|
||||
|
||||
async function triggerWipe() {
|
||||
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
|
||||
@@ -30,8 +41,10 @@ async function triggerWipe() {
|
||||
|
||||
async function triggerDryRun() {
|
||||
dryRunLoading.value = true
|
||||
dryRunResult.value = null
|
||||
try {
|
||||
await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
const result = await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
dryRunResult.value = result
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to run dry-run')
|
||||
} finally {
|
||||
@@ -39,6 +52,19 @@ async function triggerDryRun() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
||||
scheduleToggling.value = scheduleId
|
||||
try {
|
||||
await api.put(`/wipes/schedules/${scheduleId}`, { is_active: !currentlyActive })
|
||||
await wipeStore.fetchSchedules()
|
||||
toast.success(`Schedule ${currentlyActive ? 'paused' : 'activated'}`)
|
||||
} catch {
|
||||
toast.error('Failed to update schedule')
|
||||
} finally {
|
||||
scheduleToggling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await wipeStore.fetchProfiles()
|
||||
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
||||
@@ -135,6 +161,54 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dry-Run Results -->
|
||||
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-neutral-500">
|
||||
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
|
||||
</span>
|
||||
<button
|
||||
@click="dryRunResult = null"
|
||||
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
Would Delete ({{ dryRunResult.would_delete.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_delete"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Would Preserve ({{ dryRunResult.would_preserve.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_preserve"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Schedules -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Scheduled Wipes</h2>
|
||||
@@ -156,12 +230,28 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<button
|
||||
@click="toggleSchedule(schedule.id, schedule.is_active)"
|
||||
:disabled="scheduleToggling === schedule.id"
|
||||
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
|
||||
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
|
||||
>
|
||||
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user