Compare commits
51 Commits
v1.0.5
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abf0ab889 | ||
|
|
cea3d66cdd | ||
|
|
1abe57ca40 | ||
|
|
a8722a7a07 | ||
|
|
180631989a | ||
|
|
23decd9b08 | ||
|
|
8b84bba165 | ||
|
|
9a5b93dd08 | ||
|
|
3545e6f5c8 | ||
|
|
1edaaf985d | ||
|
|
f2b09b281a | ||
|
|
be57d2839a | ||
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 | ||
|
|
ef128b47d2 | ||
|
|
1bb810f851 | ||
|
|
b4d1bc8dd0 | ||
|
|
d15ea28e8f | ||
|
|
7d5966839a | ||
|
|
2668014068 | ||
|
|
bb381569e3 | ||
|
|
39622de8dc | ||
|
|
500dca48a5 | ||
|
|
b542f30dcf | ||
|
|
6461417b50 | ||
|
|
380ab2700c | ||
|
|
585e8aa3f7 | ||
|
|
4d087132db | ||
|
|
16f378eada | ||
|
|
3e1af29b38 | ||
|
|
759bd0be2e | ||
|
|
9d28fdfb65 | ||
|
|
eb57c51a24 | ||
|
|
f67b175d39 | ||
|
|
7acdd3654f | ||
|
|
57efc6a5d2 | ||
|
|
854f56a178 | ||
|
|
2df5c80928 | ||
|
|
e9f9b449b1 | ||
|
|
fee0ae2420 | ||
|
|
2b45413c20 | ||
|
|
38e6d28248 | ||
|
|
cbb3ba6586 | ||
|
|
9240feedaf | ||
|
|
7bf3e5639e | ||
|
|
fee16c3b2b |
@@ -1,4 +1,4 @@
|
||||
name: Build Companion Agent
|
||||
name: Build Host Agent
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -26,19 +26,19 @@ jobs:
|
||||
run: |
|
||||
cd companion-agent
|
||||
mkdir -p bin
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
|
||||
chmod +x bin/corrosion-companion-linux-amd64
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
|
||||
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||
|
||||
- name: Build Windows AMD64
|
||||
run: |
|
||||
cd companion-agent
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd companion-agent/bin
|
||||
sha256sum corrosion-companion-linux-amd64 > checksums.txt
|
||||
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
|
||||
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
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}" \
|
||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||
"${API_URL}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
|
||||
@@ -68,15 +68,15 @@ jobs:
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
|
||||
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
|
||||
|
||||
# Upload Windows binary
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
|
||||
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
|
||||
|
||||
# Upload checksums
|
||||
curl -s -X POST \
|
||||
@@ -89,43 +89,43 @@ jobs:
|
||||
run: |
|
||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||
|
||||
# Upload Linux binary to /companion/latest/
|
||||
# Upload Linux binary to /host-agent/latest/
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
|
||||
|
||||
# Upload Windows binary to /companion/latest/
|
||||
# Upload Windows binary to /host-agent/latest/
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||
|
||||
# Upload checksums
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/checksums.txt" \
|
||||
"${CDN_URL}/companion/latest/checksums.txt"
|
||||
"${CDN_URL}/host-agent/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"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-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"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/checksums.txt" \
|
||||
"${CDN_URL}/companion/${VERSION}/checksums.txt"
|
||||
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
|
||||
|
||||
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
|
||||
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
115
.gitea/workflows/build-host-agent.yml
Normal file
115
.gitea/workflows/build-host-agent.yml
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Build Host Agent (Rust)
|
||||
|
||||
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
|
||||
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
|
||||
# channel — /host-agent/latest/ stays on the Go build until cutover.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'agent-v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
|
||||
# (real env beats the config [env] table).
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify tag matches Cargo.toml
|
||||
run: |
|
||||
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
|
||||
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install cross toolchains
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq musl-tools gcc-mingw-w64-x86-64
|
||||
rustup target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
|
||||
|
||||
- name: Build Linux AMD64 (static musl)
|
||||
run: |
|
||||
cd corrosion-host-agent
|
||||
cargo build --release --target x86_64-unknown-linux-musl
|
||||
mkdir -p bin
|
||||
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
|
||||
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||
|
||||
- name: Build Windows AMD64 (mingw)
|
||||
run: |
|
||||
cd corrosion-host-agent
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd corrosion-host-agent/bin
|
||||
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
API_URL="${{ github.server_url }}/api/v1"
|
||||
REPO="${{ github.repository }}"
|
||||
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
|
||||
"${API_URL}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
|
||||
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @corrosion-host-agent/bin/$f \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
|
||||
done
|
||||
|
||||
- name: Upload to CDN (alpha channel)
|
||||
run: |
|
||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||
curl -s -X POST \
|
||||
-F "file=@corrosion-host-agent/bin/$f" \
|
||||
"${CDN_URL}/host-agent/alpha/$f"
|
||||
curl -s -X POST \
|
||||
-F "file=@corrosion-host-agent/bin/$f" \
|
||||
"${CDN_URL}/host-agent/${VERSION}/$f"
|
||||
done
|
||||
|
||||
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## Corrosion Host Agent (Rust) Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
### **TYPE 1: THE SCOUT (Intelligence)**
|
||||
|
||||
- **Model:** haiku
|
||||
- **Model:** sonnet[1m]
|
||||
|
||||
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
||||
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
|
||||
|
||||
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
|
||||
|
||||
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
|
||||
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
|
||||
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
|
||||
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
|
||||
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
|
||||
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
|
||||
- Verified live against production NATS: connected, heartbeats published, clean shutdown
|
||||
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
|
||||
|
||||
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
|
||||
|
||||
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||
|
||||
**Frontend:**
|
||||
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||
|
||||
**Backend (NestJS):**
|
||||
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||
|
||||
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||
|
||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||
|
||||
**Frontend:**
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -423,3 +423,15 @@ Things I discovered about myself building a sister platform across multiple sess
|
||||
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
|
||||
|
||||
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
|
||||
|
||||
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
|
||||
|
||||
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
|
||||
|
||||
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||
|
||||
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||
|
||||
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||
|
||||
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||
|
||||
@@ -34,6 +34,17 @@ import { AdminModule } from './modules/admin/admin.module';
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||
import { FilesModule } from './modules/files/files.module';
|
||||
import { LootModule } from './modules/loot/loot.module';
|
||||
import { TeleportModule } from './modules/teleport/teleport.module';
|
||||
import { GatherModule } from './modules/gather/gather.module';
|
||||
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
|
||||
import { KitsModule } from './modules/kits/kits.module';
|
||||
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
|
||||
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -103,6 +114,17 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
SetupModule,
|
||||
MigrationModule,
|
||||
ChangelogModule,
|
||||
FilesModule,
|
||||
LootModule,
|
||||
TeleportModule,
|
||||
GatherModule,
|
||||
AutoDoorsModule,
|
||||
KitsModule,
|
||||
FurnaceSplitterModule,
|
||||
BetterChatModule,
|
||||
TimedExecuteModule,
|
||||
RaidableBasesModule,
|
||||
EarlyAccessModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('autodoors_configs')
|
||||
export class AutoDoorsConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('betterchat_configs')
|
||||
export class BetterChatConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal file
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('furnacesplitter_configs')
|
||||
export class FurnaceSplitterConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('gather_configs')
|
||||
export class GatherConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('kits_configs')
|
||||
export class KitsConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('loot_profiles')
|
||||
export class LootProfile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
profile_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
loot_table: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
loot_groups: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('raidablebases_configs')
|
||||
export class RaidableBasesConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('teleport_configs')
|
||||
export class TeleportConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('timedexecute_configs')
|
||||
export class TimedExecuteConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
config_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
config_data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||
controllers: [AlertsController],
|
||||
providers: [AlertsService],
|
||||
exports: [AlertsService],
|
||||
|
||||
@@ -1,26 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
||||
|
||||
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
|
||||
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
export class AlertsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
||||
private readonly cooldowns = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertConfig)
|
||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||
@InjectRepository(AlertHistory)
|
||||
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Poll every 90 seconds.
|
||||
this.evaluatorInterval = setInterval(() => {
|
||||
this.evaluateAllAlerts().catch(err =>
|
||||
this.logger.error('Alert evaluator error', err),
|
||||
);
|
||||
}, 90_000);
|
||||
|
||||
this.logger.log('Alert evaluator started (90s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.evaluatorInterval) {
|
||||
clearInterval(this.evaluatorInterval);
|
||||
this.evaluatorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluation engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async evaluateAllAlerts(): Promise<void> {
|
||||
// Load all alert configs in one query.
|
||||
const configs = await this.alertConfigRepo.find();
|
||||
|
||||
if (configs.length === 0) return;
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
await this.evaluateForLicense(config);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Alert evaluation failed for license ${config.license_id}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateForLicense(config: AlertConfig): Promise<void> {
|
||||
// Pull the most recent server_stats record for this license.
|
||||
const stats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
if (!stats) return; // No data yet — can't evaluate.
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// --- FPS degradation alert ---
|
||||
if (config.fps_degradation_enabled && stats.fps > 0) {
|
||||
if (stats.fps < config.fps_threshold) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'fps_degradation',
|
||||
'warning',
|
||||
'FPS Degradation Detected',
|
||||
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
|
||||
{
|
||||
current_fps: stats.fps,
|
||||
threshold: config.fps_threshold,
|
||||
player_count: stats.player_count,
|
||||
recorded_at: stats.recorded_at,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Population drop alert ---
|
||||
// We need two data points to detect a *drop*, so we compare current vs
|
||||
// the max_players recorded 30 minutes ago (nearest sample).
|
||||
if (config.population_drop_enabled && stats.max_players > 0) {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const previousStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
// Use a second query to get a historical data point
|
||||
const historicalStats = await this.serverStatsRepo
|
||||
.createQueryBuilder('ss')
|
||||
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
|
||||
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
|
||||
.orderBy('ss.recorded_at', 'DESC')
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
if (historicalStats && historicalStats.player_count > 0) {
|
||||
const dropPercent =
|
||||
((historicalStats.player_count - stats.player_count) /
|
||||
historicalStats.player_count) *
|
||||
100;
|
||||
|
||||
if (dropPercent >= config.population_drop_threshold_percent) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'population_drop',
|
||||
'info',
|
||||
'Population Drop Detected',
|
||||
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`,
|
||||
{
|
||||
previous_count: historicalStats.player_count,
|
||||
current_count: stats.player_count,
|
||||
drop_percent: Math.round(dropPercent),
|
||||
threshold_percent: config.population_drop_threshold_percent,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire an alert if cooldown has expired. */
|
||||
private async maybeFireAlert(
|
||||
config: AlertConfig,
|
||||
alertType: string,
|
||||
severity: string,
|
||||
title: string,
|
||||
message: string,
|
||||
metadata: Record<string, any>,
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const cooldownKey = `${config.license_id}:${alertType}`;
|
||||
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
|
||||
|
||||
if (now - lastFired < ALERT_COOLDOWN_MS) {
|
||||
return; // Still in cooldown — skip.
|
||||
}
|
||||
|
||||
this.cooldowns.set(cooldownKey, now);
|
||||
|
||||
const history = this.alertHistoryRepo.create({
|
||||
license_id: config.license_id,
|
||||
alert_type: alertType,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
notified_discord: config.notify_discord,
|
||||
notified_pushbullet: config.notify_pushbullet,
|
||||
notified_email: config.notify_email,
|
||||
});
|
||||
|
||||
await this.alertHistoryRepo.save(history);
|
||||
|
||||
this.logger.log(
|
||||
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||
let config = await this.alertConfigRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
// Create default config if not exists
|
||||
config = this.alertConfigRepo.create({
|
||||
license_id: licenseId,
|
||||
population_drop_enabled: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import { Repository, MoreThan, Between } from 'typeorm';
|
||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
@@ -169,13 +169,18 @@ export class AnalyticsService {
|
||||
const retentionData = await Promise.all(
|
||||
recentWipes.map(async (wipe) => {
|
||||
const wipeDate = wipe.started_at;
|
||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
// Find the next wipe chronologically after this one (wipes are DESC ordered)
|
||||
const nextWipe = recentWipes
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
const endDate = nextWipe?.started_at || new Date();
|
||||
|
||||
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
|
||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
session_start: MoreThan(wipeDate!),
|
||||
session_start: Between(wipeDate!, endDate),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,6 +188,7 @@ export class AnalyticsService {
|
||||
|
||||
return {
|
||||
wipe_date: wipeDate,
|
||||
end_date: endDate,
|
||||
unique_players: uniquePlayers,
|
||||
total_sessions: sessionsInPeriod.length,
|
||||
};
|
||||
|
||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
/**
|
||||
* Bootstraps the first admin account on a fresh database.
|
||||
*
|
||||
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||
* super-admin user plus an active license — the same rows the register flow
|
||||
* would create. It never runs against a database that already has users.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminSeedService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AdminSeedService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.seedAdminIfEmpty();
|
||||
} catch (err) {
|
||||
// A failed seed must not take the API down — surface it loudly and move on
|
||||
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedAdminIfEmpty(): Promise<void> {
|
||||
const email = this.config.get<string>('admin.email');
|
||||
const password = this.config.get<string>('admin.password');
|
||||
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||
|
||||
if (!email || !password) {
|
||||
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const userCount = await this.userRepository.count();
|
||||
if (userCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = await argon2.hash(password);
|
||||
const user = this.userRepository.create({
|
||||
email: email.toLowerCase(),
|
||||
username,
|
||||
password_hash,
|
||||
email_verified: true,
|
||||
is_super_admin: true,
|
||||
});
|
||||
await this.userRepository.save(user);
|
||||
|
||||
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||
const license = this.licenseRepository.create({
|
||||
license_key: licenseKey,
|
||||
owner_user_id: user.id,
|
||||
status: 'active',
|
||||
modules_enabled: [],
|
||||
webstore_active: false,
|
||||
});
|
||||
await this.licenseRepository.save(license);
|
||||
|
||||
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||
}
|
||||
|
||||
private generateLicenseKey(): string {
|
||||
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||
return `CORR-${part1}-${part2}-${part3}`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -80,8 +80,8 @@ export class AuthService {
|
||||
|
||||
await this.licenseRepository.save(license);
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
// Generate tokens (include license_id for tenant-scoped operations)
|
||||
const tokens = await this.generateTokens(user, license.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
@@ -144,14 +144,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
// Get user's license
|
||||
// Get user's license (needed for JWT license_id claim)
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
requires_totp: false,
|
||||
@@ -191,8 +191,13 @@ export class AuthService {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Look up license for JWT claim
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate new token pair (rotating refresh tokens)
|
||||
const tokens = await this.generateTokens(user);
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
@@ -332,14 +337,18 @@ export class AuthService {
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
const payload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
};
|
||||
|
||||
if (licenseId) {
|
||||
payload.license_id = licenseId;
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||
|
||||
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { AutoDoorsService } from './autodoors.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('autodoors')
|
||||
@ApiBearerAuth()
|
||||
@Controller('autodoors')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class AutoDoorsController {
|
||||
constructor(private readonly autoDoorsService: AutoDoorsService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('autodoors.view')
|
||||
@ApiOperation({ summary: 'List AutoDoors configs' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.autoDoorsService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('autodoors.view')
|
||||
@ApiOperation({ summary: 'Get full AutoDoors config' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Create AutoDoors config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
|
||||
return this.autoDoorsService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Update AutoDoors config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAutoDoorsConfigDto,
|
||||
) {
|
||||
return this.autoDoorsService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Delete AutoDoors config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
|
||||
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AutoDoorsController } from './autodoors.controller';
|
||||
import { AutoDoorsService } from './autodoors.service';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
|
||||
controllers: [AutoDoorsController],
|
||||
providers: [AutoDoorsService, NatsService],
|
||||
exports: [AutoDoorsService],
|
||||
})
|
||||
export class AutoDoorsModule {}
|
||||
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AutoDoorsService {
|
||||
private readonly logger = new Logger(AutoDoorsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AutoDoorsConfig)
|
||||
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.autoDoorsRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
|
||||
const config = this.autoDoorsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.autoDoorsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
|
||||
const config = await this.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.autoDoorsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write AutoDoors.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload AutoDoors plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload AutoDoors',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.autoDoorsRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy AutoDoors config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import AutoDoors.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read AutoDoors.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new AutoDoors config row
|
||||
const config = this.autoDoorsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.autoDoorsRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import AutoDoors config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAutoDoorsConfigDto {
|
||||
@ApiProperty({ example: 'Default AutoDoors' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportAutoDoorsConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateAutoDoorsConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { BetterChatService } from './betterchat.service';
|
||||
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||
import { ImportBetterChatConfigDto } from './dto/import-betterchat-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('betterchat')
|
||||
@ApiBearerAuth()
|
||||
@Controller('betterchat')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class BetterChatController {
|
||||
constructor(private readonly betterChatService: BetterChatService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('betterchat.view')
|
||||
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.betterChatService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('betterchat.view')
|
||||
@ApiOperation({ summary: 'Get full BetterChat config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.betterChatService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('betterchat.manage')
|
||||
@ApiOperation({ summary: 'Create BetterChat config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
|
||||
return this.betterChatService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('betterchat.manage')
|
||||
@ApiOperation({ summary: 'Update BetterChat config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateBetterChatConfigDto,
|
||||
) {
|
||||
return this.betterChatService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('betterchat.manage')
|
||||
@ApiOperation({ summary: 'Delete BetterChat config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.betterChatService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('betterchat.manage')
|
||||
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.betterChatService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('betterchat.manage')
|
||||
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
|
||||
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BetterChatController } from './betterchat.controller';
|
||||
import { BetterChatService } from './betterchat.service';
|
||||
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
|
||||
controllers: [BetterChatController],
|
||||
providers: [BetterChatService, NatsService],
|
||||
exports: [BetterChatService],
|
||||
})
|
||||
export class BetterChatModule {}
|
||||
180
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
180
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BetterChatService {
|
||||
private readonly logger = new Logger(BetterChatService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BetterChatConfig)
|
||||
private readonly repo: Repository<BetterChatConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.repo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
|
||||
const config = this.repo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.repo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.repo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.repo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write BetterChat.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload BetterChat plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterChat',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.repo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy BetterChat config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy BetterChat config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import BetterChat.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read BetterChat.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new config row
|
||||
const config = this.repo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.repo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import BetterChat config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateBetterChatConfigDto {
|
||||
@ApiProperty({ example: 'Default Chat Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard BetterChat settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportBetterChatConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateBetterChatConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Chat Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateEarlyAccessDto {
|
||||
@ApiProperty({ example: 'admin@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
server_count?: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { EarlyAccessService } from './early-access.service';
|
||||
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||
|
||||
@ApiTags('early-access')
|
||||
@Controller()
|
||||
export class EarlyAccessController {
|
||||
constructor(private readonly earlyAccessService: EarlyAccessService) {}
|
||||
|
||||
@Public()
|
||||
@Post('early-access')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Register for early access' })
|
||||
async register(@Body() dto: CreateEarlyAccessDto) {
|
||||
return this.earlyAccessService.register(dto);
|
||||
}
|
||||
}
|
||||
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||
import { EarlyAccessController } from './early-access.controller';
|
||||
import { EarlyAccessService } from './early-access.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
|
||||
controllers: [EarlyAccessController],
|
||||
providers: [EarlyAccessService],
|
||||
})
|
||||
export class EarlyAccessModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||
|
||||
@Injectable()
|
||||
export class EarlyAccessService {
|
||||
private readonly logger = new Logger(EarlyAccessService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(EarlyAccessSignup)
|
||||
private readonly repo: Repository<EarlyAccessSignup>,
|
||||
) {}
|
||||
|
||||
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
|
||||
const existing = await this.repo.findOne({ where: { email: dto.email } });
|
||||
if (existing) {
|
||||
// Duplicate email — return friendly success rather than a 409 that would break the UX
|
||||
return { success: true, alreadyRegistered: true };
|
||||
}
|
||||
|
||||
const signup = this.repo.create({
|
||||
email: dto.email,
|
||||
server_count: dto.server_count ?? 'not specified',
|
||||
});
|
||||
|
||||
try {
|
||||
await this.repo.save(signup);
|
||||
} catch (err: unknown) {
|
||||
// Guard against a race-condition duplicate (unique constraint violation)
|
||||
const pg = err as { code?: string };
|
||||
if (pg.code === '23505') {
|
||||
return { success: true, alreadyRegistered: true };
|
||||
}
|
||||
this.logger.error('Failed to save early-access signup', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { success: true, alreadyRegistered: false };
|
||||
}
|
||||
}
|
||||
121
backend-nest/src/modules/files/files.controller.ts
Normal file
121
backend-nest/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ApiTags('Files')
|
||||
@ApiBearerAuth()
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
|
||||
@Get()
|
||||
@RequirePermission('files.view')
|
||||
async handleGet(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Query('path') path: string,
|
||||
@Query('filter') filter: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'index':
|
||||
case undefined:
|
||||
case '':
|
||||
return this.filesService.list(licenseId, path || 'server://');
|
||||
case 'search':
|
||||
return this.filesService.search(licenseId, path || 'server://', filter);
|
||||
case 'preview':
|
||||
return this.filesService.preview(licenseId, path);
|
||||
case 'download': {
|
||||
const result = await this.filesService.download(licenseId, path);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${result.filename}"`,
|
||||
);
|
||||
res.send(result.content);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
|
||||
@Post()
|
||||
@RequirePermission('files.manage')
|
||||
@UseInterceptors(FileInterceptor('upload'))
|
||||
async handlePost(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Body() body: Record<string, any>,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'delete':
|
||||
return this.filesService.delete(licenseId, body.path, body.items);
|
||||
case 'rename':
|
||||
return this.filesService.rename(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.item,
|
||||
body.name,
|
||||
);
|
||||
case 'move':
|
||||
return this.filesService.move(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'copy':
|
||||
return this.filesService.copy(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'new-folder':
|
||||
case 'mkdir':
|
||||
return this.filesService.createFolder(licenseId, body.path, body.name);
|
||||
case 'new-file':
|
||||
case 'newfile':
|
||||
return this.filesService.createFile(licenseId, body.path, body.name);
|
||||
case 'save':
|
||||
return this.filesService.save(licenseId, body.path, body.content);
|
||||
case 'upload':
|
||||
if (!file) {
|
||||
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.filesService.upload(licenseId, body.path, file);
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/files/files.module.ts
Normal file
10
backend-nest/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, NatsService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
176
backend-nest/src/modules/files/files.service.ts
Normal file
176
backend-nest/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface AgentFileResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
filename: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
private readonly logger = new Logger(FilesService.name);
|
||||
|
||||
constructor(private readonly natsService: NatsService) {}
|
||||
|
||||
// Send a file manager command to the companion agent via NATS request-reply.
|
||||
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
|
||||
private async sendFileCommand(
|
||||
licenseId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const subject = `corrosion.${licenseId}.files.cmd`;
|
||||
try {
|
||||
const response = (await this.natsService.request(
|
||||
subject,
|
||||
payload,
|
||||
30000,
|
||||
)) as AgentFileResponse | null;
|
||||
|
||||
// Offline mode — agent unreachable, return null rather than crashing
|
||||
if (response === null) {
|
||||
throw new HttpException(
|
||||
'Agent not reachable (offline mode)',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new HttpException(
|
||||
response.error || 'File operation failed',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
`File command failed on subject ${subject}: ${(error as Error).message}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
'Agent not reachable or timed out',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
|
||||
}
|
||||
|
||||
async search(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
filter: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
|
||||
}
|
||||
|
||||
async preview(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
|
||||
}
|
||||
|
||||
async download(licenseId: string, path: string): Promise<DownloadResult> {
|
||||
const result = await this.sendFileCommand(licenseId, {
|
||||
func: 'fm_preview',
|
||||
path,
|
||||
});
|
||||
const basename = path.split('/').pop() || 'download';
|
||||
return { filename: basename, content: result };
|
||||
}
|
||||
|
||||
async delete(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
|
||||
}
|
||||
|
||||
async rename(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
item: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_rename',
|
||||
path,
|
||||
items: [item],
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async move(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_move',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async copy(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_copy',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
|
||||
}
|
||||
|
||||
async createFile(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
|
||||
}
|
||||
|
||||
async save(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
|
||||
}
|
||||
|
||||
async upload(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<unknown> {
|
||||
// Encode binary content as base64 so it survives JSON serialization over NATS
|
||||
const content = file.buffer.toString('base64');
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_upload',
|
||||
path,
|
||||
filename: file.originalname,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateFurnaceSplitterConfigDto {
|
||||
@ApiProperty({ example: 'Default FurnaceSplitter' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard furnace splitter settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportFurnaceSplitterConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateFurnaceSplitterConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated FurnaceSplitter' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { FurnaceSplitterService } from './furnacesplitter.service';
|
||||
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||
import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('furnacesplitter')
|
||||
@ApiBearerAuth()
|
||||
@Controller('furnacesplitter')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class FurnaceSplitterController {
|
||||
constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('furnacesplitter.view')
|
||||
@ApiOperation({ summary: 'List furnace splitter configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.furnaceSplitterService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('furnacesplitter.view')
|
||||
@ApiOperation({ summary: 'Get full furnace splitter config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.furnaceSplitterService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('furnacesplitter.manage')
|
||||
@ApiOperation({ summary: 'Create furnace splitter config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) {
|
||||
return this.furnaceSplitterService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('furnacesplitter.manage')
|
||||
@ApiOperation({ summary: 'Update furnace splitter config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFurnaceSplitterConfigDto,
|
||||
) {
|
||||
return this.furnaceSplitterService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('furnacesplitter.manage')
|
||||
@ApiOperation({ summary: 'Delete furnace splitter config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.furnaceSplitterService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('furnacesplitter.manage')
|
||||
@ApiOperation({ summary: 'Deploy furnace splitter config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.furnaceSplitterService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('furnacesplitter.manage')
|
||||
@ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) {
|
||||
return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FurnaceSplitterController } from './furnacesplitter.controller';
|
||||
import { FurnaceSplitterService } from './furnacesplitter.service';
|
||||
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])],
|
||||
controllers: [FurnaceSplitterController],
|
||||
providers: [FurnaceSplitterService, NatsService],
|
||||
exports: [FurnaceSplitterService],
|
||||
})
|
||||
export class FurnaceSplitterModule {}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FurnaceSplitterService {
|
||||
private readonly logger = new Logger(FurnaceSplitterService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(FurnaceSplitterConfig)
|
||||
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.furnaceRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.furnaceRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) {
|
||||
const config = this.furnaceRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.furnaceRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) {
|
||||
const config = await this.furnaceRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.furnaceRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.furnaceRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write FurnaceSplitter.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload FurnaceSplitter plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload FurnaceSplitter',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.furnaceRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy furnace splitter config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import FurnaceSplitter.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read FurnaceSplitter.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new furnace splitter config row
|
||||
const config = this.furnaceRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.furnaceRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import furnace splitter config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateGatherConfigDto {
|
||||
@ApiProperty({ example: 'Default 2x Rates' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportGatherConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateGatherConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Rates' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { GatherService } from './gather.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
import { ImportGatherConfigDto } from './dto/import-gather-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('gather')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gather')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class GatherController {
|
||||
constructor(private readonly gatherService: GatherService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('gather.view')
|
||||
@ApiOperation({ summary: 'List gather configs' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.gatherService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('gather.view')
|
||||
@ApiOperation({ summary: 'Get full gather config' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Create gather config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
|
||||
return this.gatherService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Update gather config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGatherConfigDto,
|
||||
) {
|
||||
return this.gatherService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Delete gather config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Deploy gather config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Import GatherManager.json from server' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
|
||||
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GatherController } from './gather.controller';
|
||||
import { GatherService } from './gather.service';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GatherConfig])],
|
||||
controllers: [GatherController],
|
||||
providers: [GatherService, NatsService],
|
||||
exports: [GatherService],
|
||||
})
|
||||
export class GatherModule {}
|
||||
180
backend-nest/src/modules/gather/gather.service.ts
Normal file
180
backend-nest/src/modules/gather/gather.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GatherService {
|
||||
private readonly logger = new Logger(GatherService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(GatherConfig)
|
||||
private readonly gatherRepo: Repository<GatherConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.gatherRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
|
||||
const config = this.gatherRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.gatherRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
|
||||
const config = await this.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.gatherRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Gather config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write GatherManager.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload GatherManager plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload GatherManager',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.gatherRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy gather config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import GatherManager.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read GatherManager.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new gather config row
|
||||
const config = this.gatherRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.gatherRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import gather config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateKitsConfigDto {
|
||||
@ApiProperty({ example: 'Default Kits' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard kit configuration' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportKitsConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateKitsConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Kits' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { KitsService } from './kits.service';
|
||||
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||
import { ImportKitsConfigDto } from './dto/import-kits-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('kits')
|
||||
@ApiBearerAuth()
|
||||
@Controller('kits')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class KitsController {
|
||||
constructor(private readonly kitsService: KitsService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('kits.view')
|
||||
@ApiOperation({ summary: 'List kits configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.kitsService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('kits.view')
|
||||
@ApiOperation({ summary: 'Get full kits config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.kitsService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('kits.manage')
|
||||
@ApiOperation({ summary: 'Create kits config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) {
|
||||
return this.kitsService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('kits.manage')
|
||||
@ApiOperation({ summary: 'Update kits config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateKitsConfigDto,
|
||||
) {
|
||||
return this.kitsService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('kits.manage')
|
||||
@ApiOperation({ summary: 'Delete kits config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.kitsService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('kits.manage')
|
||||
@ApiOperation({ summary: 'Deploy kits config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.kitsService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('kits.manage')
|
||||
@ApiOperation({ summary: 'Import Kits.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) {
|
||||
return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KitsController } from './kits.controller';
|
||||
import { KitsService } from './kits.service';
|
||||
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([KitsConfig])],
|
||||
controllers: [KitsController],
|
||||
providers: [KitsService, NatsService],
|
||||
exports: [KitsService],
|
||||
})
|
||||
export class KitsModule {}
|
||||
180
backend-nest/src/modules/kits/kits.service.ts
Normal file
180
backend-nest/src/modules/kits/kits.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class KitsService {
|
||||
private readonly logger = new Logger(KitsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(KitsConfig)
|
||||
private readonly kitsRepo: Repository<KitsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.kitsRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.kitsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Kits config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateKitsConfigDto) {
|
||||
const config = this.kitsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.kitsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) {
|
||||
const config = await this.kitsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Kits config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.kitsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Kits config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.kitsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Kits config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write Kits.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload Kits plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload Kits',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.kitsRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy kits config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import Kits.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read Kits.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new kits config row
|
||||
const config = this.kitsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.kitsRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import kits config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface RustContainerInfo {
|
||||
prefab: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const RUST_CONTAINERS: RustContainerInfo[] = [
|
||||
// Crates
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
|
||||
// Barrels
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
|
||||
// Military
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
|
||||
// NPCs
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
|
||||
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
|
||||
// Other
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
|
||||
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'other' },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNumber, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApplyLootProfileDto {
|
||||
@ApiProperty({ example: 1, description: 'Loot multiplier', enum: [1, 2, 5, 10] })
|
||||
@IsNumber()
|
||||
@IsIn([1, 2, 5, 10])
|
||||
multiplier: number;
|
||||
}
|
||||
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateLootProfileDto {
|
||||
@ApiProperty({ example: 'Vanilla 2x' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
profile_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard 2x loot table' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_table?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
}
|
||||
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsObject, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportLootProfileDto {
|
||||
@ApiProperty({ example: 'Imported from Looty' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
profile_name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'BetterLoot LootTables.json content' })
|
||||
@IsObject()
|
||||
loot_table: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'BetterLoot LootGroups.json content' })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
}
|
||||
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateLootProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
profile_name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_table?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
loot_groups?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { LootService } from './loot.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ApplyLootProfileDto } from './dto/apply-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('loot')
|
||||
@ApiBearerAuth()
|
||||
@Controller('loot')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class LootController {
|
||||
constructor(private readonly lootService: LootService) {}
|
||||
|
||||
@Get('profiles')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'List loot profiles (summaries)' })
|
||||
getProfiles(@CurrentTenant() licenseId: string) {
|
||||
return this.lootService.getProfiles(licenseId);
|
||||
}
|
||||
|
||||
@Get('profiles/:id')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Get full loot profile with data' })
|
||||
getProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.getProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Create loot profile' })
|
||||
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateLootProfileDto) {
|
||||
return this.lootService.createProfile(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('profiles/:id')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Update loot profile' })
|
||||
updateProfile(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLootProfileDto,
|
||||
) {
|
||||
return this.lootService.updateProfile(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('profiles/:id')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Delete loot profile' })
|
||||
deleteProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.deleteProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles/:id/duplicate')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Duplicate loot profile' })
|
||||
duplicateProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.lootService.duplicateProfile(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('profiles/:id/apply')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Apply loot profile to server with multiplier' })
|
||||
applyToServer(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ApplyLootProfileDto,
|
||||
) {
|
||||
return this.lootService.applyToServer(licenseId, id, dto.multiplier);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@RequirePermission('loot.manage')
|
||||
@ApiOperation({ summary: 'Import BetterLoot/Looty JSON as new profile' })
|
||||
importProfile(@CurrentTenant() licenseId: string, @Body() dto: ImportLootProfileDto) {
|
||||
return this.lootService.importProfile(licenseId, dto);
|
||||
}
|
||||
|
||||
@Get('export/:id')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Export loot profile as BetterLoot JSON' })
|
||||
@ApiQuery({ name: 'multiplier', required: false, example: 1 })
|
||||
exportProfile(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Query('multiplier') multiplier: string,
|
||||
) {
|
||||
return this.lootService.exportProfile(licenseId, id, multiplier ? parseInt(multiplier, 10) : 1);
|
||||
}
|
||||
|
||||
@Get('containers')
|
||||
@RequirePermission('loot.view')
|
||||
@ApiOperation({ summary: 'Get list of Rust container prefabs' })
|
||||
getContainers() {
|
||||
return this.lootService.getContainers();
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LootController } from './loot.controller';
|
||||
import { LootService } from './loot.service';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LootProfile])],
|
||||
controllers: [LootController],
|
||||
providers: [LootService, NatsService],
|
||||
exports: [LootService],
|
||||
})
|
||||
export class LootModule {}
|
||||
258
backend-nest/src/modules/loot/loot.service.ts
Normal file
258
backend-nest/src/modules/loot/loot.service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
import { RUST_CONTAINERS } from './data/rust-containers';
|
||||
|
||||
@Injectable()
|
||||
export class LootService {
|
||||
private readonly logger = new Logger(LootService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LootProfile)
|
||||
private readonly lootRepo: Repository<LootProfile>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List profiles for a license (summaries — no JSONB) */
|
||||
async getProfiles(licenseId: string) {
|
||||
const profiles = await this.lootRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'profile_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { profiles };
|
||||
}
|
||||
|
||||
/** Get full profile with JSONB data */
|
||||
async getProfile(licenseId: string, profileId: string) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
return { profile };
|
||||
}
|
||||
|
||||
/** Create a new profile */
|
||||
async createProfile(licenseId: string, dto: CreateLootProfileDto) {
|
||||
const profile = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: dto.profile_name,
|
||||
description: dto.description || null,
|
||||
loot_table: dto.loot_table || {},
|
||||
loot_groups: dto.loot_groups || {},
|
||||
});
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Update an existing profile */
|
||||
async updateProfile(licenseId: string, profileId: string, dto: UpdateLootProfileDto) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
if (dto.profile_name !== undefined) profile.profile_name = dto.profile_name;
|
||||
if (dto.description !== undefined) profile.description = dto.description;
|
||||
if (dto.loot_table !== undefined) profile.loot_table = dto.loot_table;
|
||||
if (dto.loot_groups !== undefined) profile.loot_groups = dto.loot_groups;
|
||||
if (dto.is_active !== undefined) profile.is_active = dto.is_active;
|
||||
profile.updated_at = new Date();
|
||||
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Delete a profile */
|
||||
async deleteProfile(licenseId: string, profileId: string) {
|
||||
const result = await this.lootRepo.delete({ id: profileId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Loot profile not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Duplicate a profile */
|
||||
async duplicateProfile(licenseId: string, profileId: string) {
|
||||
const source = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!source) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
const copy = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: `${source.profile_name} (Copy)`,
|
||||
description: source.description,
|
||||
loot_table: JSON.parse(JSON.stringify(source.loot_table)),
|
||||
loot_groups: JSON.parse(JSON.stringify(source.loot_groups)),
|
||||
is_active: false,
|
||||
});
|
||||
const saved = await this.lootRepo.save(copy);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Apply profile to server with multiplier */
|
||||
async applyToServer(licenseId: string, profileId: string, multiplier: number) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
// Deep clone and apply multiplier
|
||||
const scaledTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||
const scaledGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||
|
||||
if (multiplier !== 1) {
|
||||
this.applyMultiplierToTable(scaledTable, multiplier);
|
||||
this.applyMultiplierToGroups(scaledGroups, multiplier);
|
||||
}
|
||||
|
||||
const lootTablesJson = JSON.stringify(scaledTable, null, 2);
|
||||
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
|
||||
|
||||
try {
|
||||
// Write LootTables.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootTables.json',
|
||||
content: lootTablesJson,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Write LootGroups.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootGroups.json',
|
||||
content: lootGroupsJson,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload BetterLoot plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterLoot',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this profile as active, deactivate others
|
||||
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.lootRepo.update(
|
||||
{ id: profileId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Profile "${profile.profile_name}" applied with ${multiplier}x multiplier`,
|
||||
profile_name: profile.profile_name,
|
||||
multiplier,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to apply loot profile: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to apply loot profile — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import BetterLoot/Looty JSON as a new profile */
|
||||
async importProfile(licenseId: string, dto: ImportLootProfileDto) {
|
||||
const profile = this.lootRepo.create({
|
||||
license_id: licenseId,
|
||||
profile_name: dto.profile_name,
|
||||
description: dto.description || 'Imported profile',
|
||||
loot_table: dto.loot_table,
|
||||
loot_groups: dto.loot_groups || {},
|
||||
});
|
||||
const saved = await this.lootRepo.save(profile);
|
||||
return { profile: saved };
|
||||
}
|
||||
|
||||
/** Export profile as BetterLoot-compatible JSON with optional multiplier */
|
||||
async exportProfile(licenseId: string, profileId: string, multiplier: number) {
|
||||
const profile = await this.lootRepo.findOne({
|
||||
where: { id: profileId, license_id: licenseId },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||
|
||||
const exportTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||
const exportGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||
|
||||
if (multiplier && multiplier !== 1) {
|
||||
this.applyMultiplierToTable(exportTable, multiplier);
|
||||
this.applyMultiplierToGroups(exportGroups, multiplier);
|
||||
}
|
||||
|
||||
return {
|
||||
profile_name: profile.profile_name,
|
||||
multiplier: multiplier || 1,
|
||||
loot_table: exportTable,
|
||||
loot_groups: exportGroups,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get static list of Rust container prefabs */
|
||||
getContainers() {
|
||||
return { containers: RUST_CONTAINERS };
|
||||
}
|
||||
|
||||
// --- Multiplier helpers ---
|
||||
|
||||
private applyMultiplierToTable(table: Record<string, any>, multiplier: number) {
|
||||
for (const prefab of Object.values(table)) {
|
||||
if (prefab?.ItemSettings) {
|
||||
this.scaleField(prefab.ItemSettings, 'ItemsMin', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'ItemsMax', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'MinScrap', multiplier);
|
||||
this.scaleField(prefab.ItemSettings, 'MaxScrap', multiplier);
|
||||
}
|
||||
if (prefab?.GuaranteedItems) {
|
||||
this.scaleItems(prefab.GuaranteedItems, multiplier);
|
||||
}
|
||||
if (prefab?.UngroupedItems) {
|
||||
this.scaleItems(prefab.UngroupedItems, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyMultiplierToGroups(groups: Record<string, any>, multiplier: number) {
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group?.GuaranteedItems) {
|
||||
this.scaleItems(group.GuaranteedItems, multiplier);
|
||||
}
|
||||
if (group?.ItemList) {
|
||||
this.scaleItems(group.ItemList, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scaleItems(items: Record<string, any>, multiplier: number) {
|
||||
for (const item of Object.values(items)) {
|
||||
this.scaleField(item, 'Min', multiplier);
|
||||
this.scaleField(item, 'Max', multiplier);
|
||||
// Recursively scale bonus items
|
||||
if (item?.BonusItems) {
|
||||
this.scaleItems(item.BonusItems, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scaleField(obj: Record<string, any>, field: string, multiplier: number) {
|
||||
if (typeof obj[field] === 'number') {
|
||||
obj[field] = Math.round(obj[field] * multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { MapLibrary } from '../../entities/map-library.entity';
|
||||
import { MapRotation } from '../../entities/map-rotation.entity';
|
||||
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
||||
import { UploadMapDto } from './dto/upload-map.dto';
|
||||
|
||||
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
|
||||
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
|
||||
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
private readonly logger = new Logger(MapsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MapLibrary)
|
||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||
@@ -22,7 +29,23 @@ export class MapsService {
|
||||
file: Express.Multer.File,
|
||||
): Promise<MapLibrary> {
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
||||
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
|
||||
|
||||
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
|
||||
const filename = `${Date.now()}_${file.originalname}`;
|
||||
const tenantDir = join(MAP_DATA_ROOT, licenseId);
|
||||
const absolutePath = join(tenantDir, filename);
|
||||
|
||||
// Relative storage path stored in DB — avoids coupling to the absolute mount point
|
||||
const storagePath = `/map_data/${licenseId}/${filename}`;
|
||||
|
||||
try {
|
||||
mkdirSync(tenantDir, { recursive: true });
|
||||
writeFileSync(absolutePath, file.buffer);
|
||||
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
|
||||
throw new InternalServerErrorException('Failed to save map file to storage');
|
||||
}
|
||||
|
||||
const map = this.mapLibraryRepo.create({
|
||||
license_id: licenseId,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService, NatsService],
|
||||
exports: [PlayersService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface Player {
|
||||
status: 'online' | 'offline' | 'banned';
|
||||
last_seen?: Date;
|
||||
ban_expires?: Date | null;
|
||||
total_sessions?: number;
|
||||
total_playtime_seconds?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -18,43 +21,86 @@ export class PlayersService {
|
||||
constructor(
|
||||
@InjectRepository(PlayerAction)
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get recent players for a license
|
||||
* Get players for a license.
|
||||
*
|
||||
* TODO: This needs a player_sessions table to track online/offline status.
|
||||
* For now, we query player_actions to get a list of players who have had actions.
|
||||
* Primary source: player_sessions table (tracks session lifecycles).
|
||||
* Secondary source: player_actions table (determines ban status).
|
||||
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
|
||||
* A player with an open session (session_end IS NULL) is shown as online.
|
||||
*/
|
||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||
const actions = await this.actionRepo
|
||||
// Get distinct players from session history
|
||||
const sessions = await this.sessionRepo
|
||||
.createQueryBuilder('session')
|
||||
.where('session.license_id = :licenseId', { licenseId })
|
||||
.orderBy('session.session_start', 'DESC')
|
||||
.getMany();
|
||||
|
||||
// Build per-player session aggregates
|
||||
const playerMap = new Map<string, Player>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!playerMap.has(session.steam_id)) {
|
||||
const isOnline = session.session_end === null;
|
||||
playerMap.set(session.steam_id, {
|
||||
steam_id: session.steam_id,
|
||||
player_name: session.player_name,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
last_seen: session.session_start,
|
||||
ban_expires: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = playerMap.get(session.steam_id)!;
|
||||
entry.total_sessions = (entry.total_sessions || 0) + 1;
|
||||
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
|
||||
}
|
||||
|
||||
// Overlay ban status from most recent action per player
|
||||
const recentActions = await this.actionRepo
|
||||
.createQueryBuilder('action')
|
||||
.where('action.license_id = :licenseId', { licenseId })
|
||||
.orderBy('action.created_at', 'DESC')
|
||||
.take(100)
|
||||
.getMany();
|
||||
|
||||
// Group by steam_id to get unique players
|
||||
const playerMap = new Map<string, Player>();
|
||||
// Track the most recent action per steam_id to determine ban state
|
||||
const latestActionBySteamId = new Map<string, PlayerAction>();
|
||||
for (const action of recentActions) {
|
||||
if (!latestActionBySteamId.has(action.steam_id)) {
|
||||
latestActionBySteamId.set(action.steam_id, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (!playerMap.has(action.steam_id)) {
|
||||
// Determine status based on latest action
|
||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
||||
if (action.action_type === 'ban') {
|
||||
status = 'banned';
|
||||
}
|
||||
|
||||
playerMap.set(action.steam_id, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status,
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
for (const [steamId, action] of latestActionBySteamId) {
|
||||
if (action.action_type === 'ban') {
|
||||
// Add player from actions even if they have no sessions
|
||||
if (!playerMap.has(steamId)) {
|
||||
playerMap.set(steamId, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status: 'banned',
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
} else {
|
||||
const entry = playerMap.get(steamId)!;
|
||||
entry.status = 'banned';
|
||||
entry.ban_expires = action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
});
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +110,9 @@ export class PlayersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a moderation action on a player
|
||||
* Perform a moderation action on a player.
|
||||
* Supported actions: kick, ban, unban, warn, note.
|
||||
* kick/ban/unban are forwarded to the game server via NATS.
|
||||
*/
|
||||
async performAction(
|
||||
licenseId: string,
|
||||
@@ -84,8 +132,8 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// For kick/ban, send NATS command to the server
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
@@ -57,9 +71,38 @@ export class PluginsController {
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory' })
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
|
||||
@ApiQuery({ name: 'q', required: true, example: 'kits' })
|
||||
searchUmod(@Query('q') query: string) {
|
||||
return this.pluginsService.searchUmod(query);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
|
||||
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
|
||||
@ApiQuery({ name: 'page', required: false, example: 1 })
|
||||
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
|
||||
browseUmod(
|
||||
@Query('query') query: string,
|
||||
@Query('page') page: string,
|
||||
@Query('sort') sort: string,
|
||||
) {
|
||||
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@RequirePermission('plugin.manage')
|
||||
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadPlugin(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.pluginsService.uploadPlugin(licenseId, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
@@ -6,9 +6,16 @@ import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
private readonly logger = new Logger(PluginsService.name);
|
||||
private readonly umodCache = new Map<string, UmodCacheEntry>();
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
@@ -45,7 +52,21 @@ export class PluginsService {
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
return this.pluginRegistryRepo.save(plugin);
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
try {
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_install',
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
|
||||
@@ -108,10 +129,107 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
|
||||
// uMod API integration pending
|
||||
// Legacy stub — use browseUmod via GET /plugins/browse for real results
|
||||
return {
|
||||
results: [],
|
||||
message: 'uMod search integration not yet configured',
|
||||
message: 'Use GET /plugins/browse for uMod search',
|
||||
};
|
||||
}
|
||||
|
||||
async browseUmod(query: string, page = 1, sort = 'downloads'): Promise<unknown> {
|
||||
const cacheKey = `${query ?? ''}:${page}:${sort}`;
|
||||
const cached = this.umodCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
this.logger.debug(`uMod cache hit for key "${cacheKey}"`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
sort,
|
||||
sortdir: 'desc',
|
||||
'categories[]': 'rust',
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
params.set('query', query.trim());
|
||||
}
|
||||
|
||||
const url = `https://umod.org/plugins/search.json?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'CorrosionPanel/1.0' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`uMod API returned ${response.status} for query "${query}"`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.umodCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort} → ${(data as any)?.total ?? '?'} results`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise<PluginRegistry> {
|
||||
// Validate extension
|
||||
const originalName = file.originalname ?? '';
|
||||
if (!originalName.toLowerCase().endsWith('.cs')) {
|
||||
throw new BadRequestException('Only .cs plugin files are accepted');
|
||||
}
|
||||
|
||||
// Validate size (5 MB)
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Plugin file exceeds the 5 MB limit');
|
||||
}
|
||||
|
||||
// Derive plugin name from filename (strip .cs)
|
||||
const pluginName = originalName.replace(/\.cs$/i, '');
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.pluginRegistryRepo.findOne({
|
||||
where: { license_id: licenseId, plugin_name: pluginName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Plugin "${pluginName}" is already installed`);
|
||||
}
|
||||
|
||||
// Persist record
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: pluginName,
|
||||
source: 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateRaidableBasesConfigDto {
|
||||
@ApiProperty({ example: 'Default RaidableBases Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard RaidableBases settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportRaidableBasesConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateRaidableBasesConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { RaidableBasesService } from './raidablebases.service';
|
||||
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||
import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('raidablebases')
|
||||
@ApiBearerAuth()
|
||||
@Controller('raidablebases')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class RaidableBasesController {
|
||||
constructor(private readonly raidableBasesService: RaidableBasesService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('raidablebases.view')
|
||||
@ApiOperation({ summary: 'List RaidableBases configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.raidableBasesService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('raidablebases.view')
|
||||
@ApiOperation({ summary: 'Get full RaidableBases config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.raidableBasesService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('raidablebases.manage')
|
||||
@ApiOperation({ summary: 'Create RaidableBases config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) {
|
||||
return this.raidableBasesService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('raidablebases.manage')
|
||||
@ApiOperation({ summary: 'Update RaidableBases config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateRaidableBasesConfigDto,
|
||||
) {
|
||||
return this.raidableBasesService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('raidablebases.manage')
|
||||
@ApiOperation({ summary: 'Delete RaidableBases config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.raidableBasesService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('raidablebases.manage')
|
||||
@ApiOperation({ summary: 'Deploy RaidableBases config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.raidableBasesService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('raidablebases.manage')
|
||||
@ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) {
|
||||
return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RaidableBasesController } from './raidablebases.controller';
|
||||
import { RaidableBasesService } from './raidablebases.service';
|
||||
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([RaidableBasesConfig])],
|
||||
controllers: [RaidableBasesController],
|
||||
providers: [RaidableBasesService, NatsService],
|
||||
exports: [RaidableBasesService],
|
||||
})
|
||||
export class RaidableBasesModule {}
|
||||
180
backend-nest/src/modules/raidablebases/raidablebases.service.ts
Normal file
180
backend-nest/src/modules/raidablebases/raidablebases.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RaidableBasesService {
|
||||
private readonly logger = new Logger(RaidableBasesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(RaidableBasesConfig)
|
||||
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.raidableBasesRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.raidableBasesRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('RaidableBases config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
|
||||
const config = this.raidableBasesRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.raidableBasesRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
|
||||
const config = await this.raidableBasesRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('RaidableBases config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.raidableBasesRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.raidableBasesRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('RaidableBases config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write RaidableBases.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload RaidableBases plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload RaidableBases',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.raidableBasesRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy RaidableBases config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy RaidableBases config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import RaidableBases.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read RaidableBases.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new RaidableBases config row
|
||||
const config = this.raidableBasesRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.raidableBasesRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import RaidableBases config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
providers: [SchedulesService, NatsService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
|
||||
@@ -1,21 +1,220 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
private executorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Bootstrap: calculate next_run for any task that has none.
|
||||
this.bootstrapNextRuns().catch(err =>
|
||||
this.logger.error('Failed to bootstrap next_run values', err),
|
||||
);
|
||||
|
||||
// Poll every 60 seconds for due tasks.
|
||||
this.executorInterval = setInterval(() => {
|
||||
this.executeDueTasks().catch(err =>
|
||||
this.logger.error('Schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
this.logger.log('Schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.executorInterval) {
|
||||
clearInterval(this.executorInterval);
|
||||
this.executorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** On startup, stamp next_run on tasks that don't have one yet. */
|
||||
private async bootstrapNextRuns(): Promise<void> {
|
||||
const tasks = await this.taskRepository.find({
|
||||
where: { is_active: true, next_run: null as any },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
if (next) {
|
||||
task.next_run = next;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find all active tasks whose next_run <= now and fire them. */
|
||||
private async executeDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
is_active: true,
|
||||
next_run: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (dueTasks.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
|
||||
|
||||
for (const task of dueTasks) {
|
||||
try {
|
||||
await this.executeTask(task);
|
||||
|
||||
// Advance next_run.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to execute task ${task.id} (${task.task_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
// Still advance next_run so we don't hammer on a broken task.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a single task via NATS based on its task_type. */
|
||||
private async executeTask(task: ScheduledTask): Promise<void> {
|
||||
const { license_id, task_type, task_name, task_config } = task;
|
||||
|
||||
this.logger.log(
|
||||
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
|
||||
);
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
const command = (task_config?.command as string) ?? '';
|
||||
if (!command) {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
|
||||
return await this.taskRepository.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -27,31 +226,23 @@ export class SchedulesService {
|
||||
licenseId: string,
|
||||
dto: CreateTaskDto,
|
||||
): Promise<ScheduledTask> {
|
||||
// Validate cron expression is parseable
|
||||
// In production, you'd use a cron parser library to validate
|
||||
// For now, we rely on the regex in the DTO
|
||||
|
||||
// Set default timezone if not provided
|
||||
const timezone = dto.timezone || 'UTC';
|
||||
const now = new Date();
|
||||
const next = nextCronDate(dto.cron_expression, now);
|
||||
|
||||
const task = this.taskRepository.create({
|
||||
license_id: licenseId,
|
||||
task_type: dto.task_type,
|
||||
task_name: dto.task_name,
|
||||
cron_expression: dto.cron_expression,
|
||||
timezone: timezone,
|
||||
timezone,
|
||||
task_config: dto.task_config || {},
|
||||
is_active: true,
|
||||
next_run: null, // Would be calculated by scheduler
|
||||
created_at: new Date(),
|
||||
next_run: next ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
const saved = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
|
||||
// This would send a NATS message to the scheduler service to register the task
|
||||
|
||||
return saved;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
@@ -70,15 +261,15 @@ export class SchedulesService {
|
||||
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.assign(task, dto);
|
||||
|
||||
const updated = await this.taskRepository.save(task);
|
||||
// Recalculate next_run if the cron expression changed.
|
||||
if (dto.cron_expression) {
|
||||
const next = nextCronDate(dto.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
// TODO: Update task registration with scheduler
|
||||
// Send NATS message to update the task in tokio-cron-scheduler
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async deleteTask(licenseId: string, taskId: string) {
|
||||
@@ -94,10 +285,6 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
await this.taskRepository.delete(taskId);
|
||||
|
||||
// TODO: Unregister task from scheduler
|
||||
// Send NATS message to remove the task from tokio-cron-scheduler
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -114,11 +301,13 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
task.is_active = enabled;
|
||||
const updated = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Enable/disable task in scheduler
|
||||
// Send NATS message to pause or resume the task
|
||||
// When re-enabling, calculate next_run if it's missing.
|
||||
if (enabled && !task.next_run) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +73,11 @@ export class ServersController {
|
||||
) {
|
||||
return await this.serversService.deployServer(licenseId, dto);
|
||||
}
|
||||
|
||||
@Post('install-oxide')
|
||||
@RequirePermission('server.manage')
|
||||
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
|
||||
async installOxide(@CurrentTenant() licenseId: string) {
|
||||
return await this.serversService.installOxide(licenseId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
@@ -9,6 +9,8 @@ import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServersService {
|
||||
private readonly logger = new Logger(ServersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly connectionRepo: Repository<ServerConnection>,
|
||||
@@ -60,8 +62,14 @@ export class ServersService {
|
||||
* Send a console command to the server via NATS
|
||||
*/
|
||||
async sendCommand(licenseId: string, command: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
return { output: 'Command sent' };
|
||||
try {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
|
||||
throw new InternalServerErrorException('Failed to dispatch command to server');
|
||||
}
|
||||
return { success: true, message: 'Command dispatched' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,4 +103,12 @@ export class ServersService {
|
||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||
return { message: 'Deployment started' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Oxide/uMod via companion agent
|
||||
*/
|
||||
async installOxide(licenseId: string) {
|
||||
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||
return { message: 'Oxide installation started' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
|
||||
import { StatusService } from './status.service';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
|
||||
],
|
||||
controllers: [StatusController],
|
||||
providers: [StatusService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +13,8 @@ export class StatusService {
|
||||
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
@InjectRepository(License)
|
||||
private readonly licenseRepo: Repository<License>,
|
||||
) {}
|
||||
@@ -32,12 +35,18 @@ export class StatusService {
|
||||
where: { license_id: config.license_id },
|
||||
});
|
||||
|
||||
// Fetch the most recent stats row for this server to get live player counts
|
||||
const latestStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
server_name: license?.subdomain || 'Unknown Server',
|
||||
server_name: license?.server_name || license?.subdomain || 'Unknown Server',
|
||||
subdomain: license?.subdomain || null,
|
||||
status: connection?.connection_status || 'offline',
|
||||
player_count: 0, // Would need real-time data
|
||||
max_players: 0,
|
||||
player_count: latestStats?.player_count ?? 0,
|
||||
max_players: latestStats?.max_players ?? 0,
|
||||
steam_connect_url: config.steam_connect_url,
|
||||
motd: config.motd,
|
||||
discord_invite_url: config.discord_invite_url,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
|
||||
import { StoreService } from './store.service';
|
||||
import { Module as ModuleEntity } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
|
||||
controllers: [StoreController],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Module } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Module)
|
||||
private readonly moduleRepo: Repository<Module>,
|
||||
@InjectRepository(ModulePurchase)
|
||||
private readonly purchaseRepo: Repository<ModulePurchase>,
|
||||
@InjectRepository(ModuleInstallation)
|
||||
private readonly installationRepo: Repository<ModuleInstallation>,
|
||||
) {}
|
||||
|
||||
async getCatalog(): Promise<Module[]> {
|
||||
@@ -26,14 +31,19 @@ export class StoreService {
|
||||
order: { purchased_at: 'DESC' },
|
||||
});
|
||||
|
||||
const installations = await this.installationRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
relations: ['module'],
|
||||
});
|
||||
|
||||
return {
|
||||
purchased: purchases,
|
||||
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
|
||||
installed: installations,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
||||
// Check if already purchased
|
||||
// Check if already purchased.
|
||||
const existing = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -50,15 +60,15 @@ export class StoreService {
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`, // Stub
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
}
|
||||
|
||||
async installModule(licenseId: string, moduleId: string) {
|
||||
// Verify purchase exists
|
||||
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
|
||||
// Verify purchase exists.
|
||||
const purchase = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -67,11 +77,44 @@ export class StoreService {
|
||||
throw new ForbiddenException('Module not purchased');
|
||||
}
|
||||
|
||||
// Stub - would create module_installation record
|
||||
return {
|
||||
message: 'Module installed successfully',
|
||||
// Verify module exists.
|
||||
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
|
||||
if (!module) {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Idempotent: return existing installation record if one already exists.
|
||||
const existing = await this.installationRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If previously failed, reset to pending so it can be retried.
|
||||
if (existing.status === 'failed') {
|
||||
existing.status = 'installed';
|
||||
existing.installed_at = new Date();
|
||||
existing.error_message = null;
|
||||
return this.installationRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create installation record and mark as installed.
|
||||
// In a full implementation this would trigger an async deployment pipeline.
|
||||
const installation = this.installationRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
status: 'installed',
|
||||
};
|
||||
installed_at: new Date(),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const saved = await this.installationRepo.save(installation);
|
||||
|
||||
this.logger.log(
|
||||
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTeleportConfigDto {
|
||||
@ApiProperty({ example: 'Default Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportTeleportConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateTeleportConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeleportService } from './teleport.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('teleport')
|
||||
@ApiBearerAuth()
|
||||
@Controller('teleport')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class TeleportController {
|
||||
constructor(private readonly teleportService: TeleportService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('teleport.view')
|
||||
@ApiOperation({ summary: 'List teleport configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.teleportService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('teleport.view')
|
||||
@ApiOperation({ summary: 'Get full teleport config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Create teleport config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
|
||||
return this.teleportService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Update teleport config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTeleportConfigDto,
|
||||
) {
|
||||
return this.teleportService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Delete teleport config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Deploy teleport config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.teleportService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('teleport.manage')
|
||||
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
|
||||
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TeleportController } from './teleport.controller';
|
||||
import { TeleportService } from './teleport.service';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TeleportConfig])],
|
||||
controllers: [TeleportController],
|
||||
providers: [TeleportService, NatsService],
|
||||
exports: [TeleportService],
|
||||
})
|
||||
export class TeleportModule {}
|
||||
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TeleportService {
|
||||
private readonly logger = new Logger(TeleportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TeleportConfig)
|
||||
private readonly teleportRepo: Repository<TeleportConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.teleportRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
|
||||
const config = this.teleportRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.teleportRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Teleport config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write NTeleportation.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload NTeleportation plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload NTeleportation',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.teleportRepo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy teleport config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import NTeleportation.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read NTeleportation.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new teleport config row
|
||||
const config = this.teleportRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.teleportRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import teleport config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTimedExecuteConfigDto {
|
||||
@ApiProperty({ example: 'Default Timer Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard TimedExecute settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportTimedExecuteConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateTimedExecuteConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Timer Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TimedExecuteService } from './timedexecute.service';
|
||||
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||
import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-config.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('timedexecute')
|
||||
@ApiBearerAuth()
|
||||
@Controller('timedexecute')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class TimedExecuteController {
|
||||
constructor(private readonly timedExecuteService: TimedExecuteService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('timedexecute.view')
|
||||
@ApiOperation({ summary: 'List TimedExecute configs (summaries)' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.timedExecuteService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('timedexecute.view')
|
||||
@ApiOperation({ summary: 'Get full TimedExecute config with data' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.timedExecuteService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('timedexecute.manage')
|
||||
@ApiOperation({ summary: 'Create TimedExecute config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) {
|
||||
return this.timedExecuteService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('timedexecute.manage')
|
||||
@ApiOperation({ summary: 'Update TimedExecute config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTimedExecuteConfigDto,
|
||||
) {
|
||||
return this.timedExecuteService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('timedexecute.manage')
|
||||
@ApiOperation({ summary: 'Delete TimedExecute config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.timedExecuteService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('timedexecute.manage')
|
||||
@ApiOperation({ summary: 'Deploy TimedExecute config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.timedExecuteService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('timedexecute.manage')
|
||||
@ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) {
|
||||
return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal file
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TimedExecuteController } from './timedexecute.controller';
|
||||
import { TimedExecuteService } from './timedexecute.service';
|
||||
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TimedExecuteConfig])],
|
||||
controllers: [TimedExecuteController],
|
||||
providers: [TimedExecuteService, NatsService],
|
||||
exports: [TimedExecuteService],
|
||||
})
|
||||
export class TimedExecuteModule {}
|
||||
180
backend-nest/src/modules/timedexecute/timedexecute.service.ts
Normal file
180
backend-nest/src/modules/timedexecute/timedexecute.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TimedExecuteService {
|
||||
private readonly logger = new Logger(TimedExecuteService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TimedExecuteConfig)
|
||||
private readonly repo: Repository<TimedExecuteConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.repo.find({
|
||||
where: { license_id: licenseId },
|
||||
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return { configs };
|
||||
}
|
||||
|
||||
/** Get full config with JSONB data */
|
||||
async getConfig(licenseId: string, configId: string) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('TimedExecute config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) {
|
||||
const config = this.repo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.repo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('TimedExecute config not found');
|
||||
|
||||
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||
if (dto.description !== undefined) config.description = dto.description;
|
||||
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||
config.updated_at = new Date();
|
||||
|
||||
const saved = await this.repo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.repo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('TimedExecute config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.repo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('TimedExecute config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write TimedExecute.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload TimedExecute plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload TimedExecute',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.repo.update(
|
||||
{ id: configId, license_id: licenseId },
|
||||
{ is_active: true, updated_at: new Date() },
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Config "${config.config_name}" deployed to server`,
|
||||
config_name: config.config_name,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deploy TimedExecute config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy TimedExecute config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import TimedExecute.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read TimedExecute.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new config row
|
||||
const config = this.repo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.repo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import TimedExecute config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,19 +126,112 @@ export class WipesService {
|
||||
would_delete: string[];
|
||||
would_preserve: string[];
|
||||
estimated_duration_seconds: number;
|
||||
profile_name: string | null;
|
||||
notes: string[];
|
||||
}> {
|
||||
// Stub implementation - real logic would analyze wipe profile config
|
||||
const mockResult = {
|
||||
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
|
||||
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
|
||||
estimated_duration_seconds: 45,
|
||||
};
|
||||
|
||||
if (dto.wipe_type === 'full') {
|
||||
mockResult.would_delete.push('oxide/data/*');
|
||||
mockResult.estimated_duration_seconds = 120;
|
||||
// Resolve profile config if a profile ID was supplied.
|
||||
let profile: WipeProfile | null = null;
|
||||
if (dto.wipe_profile_id) {
|
||||
profile = await this.wipeProfileRepo.findOne({
|
||||
where: { id: dto.wipe_profile_id, license_id: licenseId },
|
||||
});
|
||||
}
|
||||
|
||||
return mockResult;
|
||||
if (!profile && dto.wipe_profile_id) {
|
||||
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
|
||||
}
|
||||
|
||||
const notes: string[] = [];
|
||||
|
||||
// Base files affected by all wipe types.
|
||||
const would_delete: string[] = ['*.map', '*.sav'];
|
||||
const would_preserve: string[] = [
|
||||
'oxide/',
|
||||
'oxide/plugins/',
|
||||
'cfg/',
|
||||
'server.cfg',
|
||||
];
|
||||
|
||||
// Blueprint wipe additions.
|
||||
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
|
||||
would_delete.push('player.blueprints.db', 'player.tech.db');
|
||||
}
|
||||
|
||||
// Full wipe: also clear player data and oxide data.
|
||||
if (dto.wipe_type === 'full') {
|
||||
would_delete.push(
|
||||
'player.deaths.db',
|
||||
'player.identities.db',
|
||||
'player.states.db',
|
||||
'player.tokens.db',
|
||||
'oxide/data/*',
|
||||
);
|
||||
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
|
||||
}
|
||||
|
||||
// Factor in pre_wipe_config from the profile (if set).
|
||||
let estimatedSeconds = 45;
|
||||
|
||||
if (profile) {
|
||||
const pre = profile.pre_wipe_config as Record<string, any>;
|
||||
const post = profile.post_wipe_config as Record<string, any>;
|
||||
|
||||
if (pre?.backup_before_wipe) {
|
||||
estimatedSeconds += 60;
|
||||
notes.push('Pre-wipe backup will run before deletion (+60s)');
|
||||
would_preserve.push('backups/');
|
||||
}
|
||||
|
||||
if (pre?.kick_players_before_wipe) {
|
||||
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
|
||||
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
|
||||
if (maxWarning > 0) {
|
||||
estimatedSeconds += maxWarning * 60;
|
||||
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (post?.verify_server_started) {
|
||||
estimatedSeconds += 30;
|
||||
notes.push('Post-wipe: server health check will run (+30s)');
|
||||
}
|
||||
|
||||
if (post?.rollback_on_failure) {
|
||||
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
|
||||
}
|
||||
|
||||
if (post?.max_restart_attempts) {
|
||||
const attempts = post.max_restart_attempts as number;
|
||||
if (attempts > 1) {
|
||||
estimatedSeconds += (attempts - 1) * 15;
|
||||
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notes.push('No profile selected — using default wipe behavior');
|
||||
}
|
||||
|
||||
// Account for world size in time estimate.
|
||||
// Larger worlds take longer to clear from disk (rough heuristic).
|
||||
// We don't have world_size here without querying server_config,
|
||||
// so apply a static estimate per wipe type.
|
||||
if (dto.wipe_type === 'full') {
|
||||
estimatedSeconds += 75;
|
||||
} else if (dto.wipe_type === 'blueprint') {
|
||||
estimatedSeconds += 10;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
|
||||
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
|
||||
);
|
||||
|
||||
return {
|
||||
would_delete,
|
||||
would_preserve,
|
||||
estimated_duration_seconds: estimatedSeconds,
|
||||
profile_name: profile?.profile_name ?? null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
|
||||
this.emit(licenseId, 'deploy_status', data);
|
||||
});
|
||||
|
||||
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
|
||||
const licenseId = subject.split('.')[1];
|
||||
this.emit(licenseId, 'oxide_status', data);
|
||||
});
|
||||
|
||||
this.logger.log('NATS bridge subscriptions initialized');
|
||||
}
|
||||
|
||||
|
||||
@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish an Oxide install command to a specific license's companion agent */
|
||||
async sendOxideInstallCommand(licenseId: string): Promise<void> {
|
||||
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
|
||||
action: 'install_oxide',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user