Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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');
|
||||
@@ -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,12 @@ 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,
|
||||
},
|
||||
);
|
||||
// Generate new token pair (rotating refresh tokens)
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
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,14 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -86,4 +87,12 @@ 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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ 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.logger.log('NATS bridge subscriptions initialized');
|
||||
}
|
||||
|
||||
|
||||
@@ -70,4 +70,13 @@ 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -17,16 +18,19 @@ 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:""`
|
||||
|
||||
// Optional settings
|
||||
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||
@@ -44,11 +48,21 @@ 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(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
||||
|
||||
// Create context with signal handling for graceful shutdown
|
||||
@@ -73,6 +87,7 @@ func main() {
|
||||
GameServerPath: cfg.GameServerPath,
|
||||
GameServerArgs: cfg.GameServerArgs,
|
||||
Version: version,
|
||||
InstallDir: cfg.InstallDir,
|
||||
}
|
||||
|
||||
// Start daemon
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||
@@ -22,6 +23,7 @@ type DaemonConfig struct {
|
||||
GameServerPath string
|
||||
GameServerArgs string
|
||||
Version string
|
||||
InstallDir string
|
||||
}
|
||||
|
||||
// Daemon manages the companion agent's main operations
|
||||
@@ -31,6 +33,7 @@ type Daemon struct {
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
updater *update.Updater
|
||||
deployer *deploy.Deployer
|
||||
subscriptions []*nats.Subscription
|
||||
}
|
||||
|
||||
@@ -44,9 +47,26 @@ 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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewDaemon creates a new daemon instance
|
||||
@@ -54,6 +74,8 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||
fileOps := files.NewOperations()
|
||||
updater := update.NewUpdater(cfg.Version)
|
||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||
|
||||
d := &Daemon{
|
||||
nc: nc,
|
||||
@@ -61,6 +83,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
updater: updater,
|
||||
deployer: deployer,
|
||||
}
|
||||
|
||||
return d, nil
|
||||
@@ -90,6 +113,11 @@ 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)
|
||||
}
|
||||
|
||||
log.Println("All subscriptions active")
|
||||
|
||||
// Start heartbeat ticker
|
||||
@@ -267,6 +295,49 @@ 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
|
||||
}
|
||||
|
||||
// handleFileOperation processes file operation requests
|
||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||
// Parse common fields
|
||||
@@ -325,17 +396,18 @@ 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),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,30 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
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 +85,17 @@ export const useServerStore = defineStore('server', () => {
|
||||
config,
|
||||
stats,
|
||||
isLoading,
|
||||
deploymentStatus,
|
||||
isDeploying,
|
||||
fetchServer,
|
||||
updateConfig,
|
||||
sendCommand,
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
deployServer,
|
||||
updateDeploymentStatus,
|
||||
clearDeploymentStatus,
|
||||
updateStats,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface AuthResponse {
|
||||
refresh_token: string
|
||||
requires_totp: boolean
|
||||
user: User
|
||||
license: License | null
|
||||
}
|
||||
|
||||
export interface ServerConnection {
|
||||
@@ -423,3 +424,21 @@ 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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
Download,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Rocket,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
const server = useServerStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -23,6 +28,20 @@ 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 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 +67,78 @@ 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 {
|
||||
// Error handled in store
|
||||
} 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 form = ref({
|
||||
server_name: '',
|
||||
max_players: 0,
|
||||
@@ -115,6 +186,13 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -244,7 +322,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 +330,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 +340,192 @@ 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>
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
@@ -68,6 +71,9 @@ async function handleTotpVerify() {
|
||||
})
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: unknown) {
|
||||
totpCode.value = ''
|
||||
|
||||
@@ -57,6 +57,9 @@ async function handleRegister() {
|
||||
})
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/setup')
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
|
||||
|
||||
// ---------- Email capture ----------
|
||||
|
||||
@@ -9,20 +9,20 @@ interface License {
|
||||
id: string
|
||||
license_key: string
|
||||
owner_email: string
|
||||
server_name: string
|
||||
server_name: string | null
|
||||
status: 'active' | 'suspended' | 'expired' | 'revoked'
|
||||
created_at: string
|
||||
expires_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
interface LicenseDetail {
|
||||
id: string
|
||||
license_key: string
|
||||
owner_email: string
|
||||
server_name: string
|
||||
server_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
expires_at: string | null
|
||||
team_count: number
|
||||
wipe_count: number
|
||||
server_connection: {
|
||||
@@ -67,8 +67,11 @@ const statusBadgeClass: Record<string, string> = {
|
||||
revoked: 'bg-red-500/10 text-red-400',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime()) || d.getTime() === 0) return '—'
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -80,7 +83,7 @@ async function fetchLicenses() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.value.toString(),
|
||||
per_page: perPage.toString(),
|
||||
limit: perPage.toString(),
|
||||
})
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
||||
|
||||
Reference in New Issue
Block a user