Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d50a7fd049 | |||
| 55e0a3cde6 | |||
| cb3a90be35 | |||
| 276150a769 | |||
| c1adb94abb |
@@ -10,7 +10,12 @@ on:
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
PNPM_VERSION: "9.15.4"
|
||||
RUST_TOOLCHAIN: "1.83"
|
||||
# 1.85 is the floor: dependencies now ship edition 2024, which older
|
||||
# Cargo refuses to even parse. Keep this in step with the toolchain
|
||||
# pinned in apps/daemon/Dockerfile.
|
||||
RUST_TOOLCHAIN: "1.97"
|
||||
# Static client only; the daemon comes from the socket the runner mounts.
|
||||
DOCKER_CLI_VERSION: "29.7.1"
|
||||
|
||||
jobs:
|
||||
# --- Lint + TypeScript Check ---
|
||||
@@ -56,8 +61,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Self-hosted act runners run as root in a container that has no sudo,
|
||||
# while GitHub-hosted runners need it. Pick whichever exists.
|
||||
- name: Install protoc
|
||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||
run: |
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y protobuf-compiler
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -88,6 +99,26 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The runner executes jobs inside a container that has no docker CLI,
|
||||
# while act_runner mounts the host's socket at /var/run/docker.sock.
|
||||
# Install just the client when it is missing, then prove the socket is
|
||||
# actually reachable — the two failure modes look nothing alike and the
|
||||
# message should say which one happened.
|
||||
- name: Ensure docker CLI
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
|
||||
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
|
||||
else wget -qO /tmp/docker.tgz "$url"; fi
|
||||
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
fi
|
||||
docker --version
|
||||
docker version >/dev/null 2>&1 || {
|
||||
echo "The docker socket is not reachable from this job."
|
||||
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build API image
|
||||
run: docker build -f apps/api/Dockerfile -t gamepanel-api:ci .
|
||||
|
||||
@@ -118,6 +149,26 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The runner executes jobs inside a container that has no docker CLI,
|
||||
# while act_runner mounts the host's socket at /var/run/docker.sock.
|
||||
# Install just the client when it is missing, then prove the socket is
|
||||
# actually reachable — the two failure modes look nothing alike and the
|
||||
# message should say which one happened.
|
||||
- name: Ensure docker CLI
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
|
||||
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
|
||||
else wget -qO /tmp/docker.tgz "$url"; fi
|
||||
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
fi
|
||||
docker --version
|
||||
docker version >/dev/null 2>&1 || {
|
||||
echo "The docker socket is not reachable from this job."
|
||||
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" |
|
||||
|
||||
@@ -3,3 +3,7 @@ dist
|
||||
.turbo
|
||||
pnpm-lock.yaml
|
||||
apps/daemon/target
|
||||
|
||||
# Captured bring-up reports, not maintained sources — reflowing them would
|
||||
# only churn a record of what happened.
|
||||
conduit-bringup-artifacts
|
||||
|
||||
+57
-47
@@ -1,6 +1,7 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide covers three deployment methods:
|
||||
|
||||
1. **Development Setup** — for local development
|
||||
2. **Docker Production** — single-command deployment with Docker Compose
|
||||
3. **Manual Production** — step-by-step on Ubuntu 22.04+
|
||||
@@ -10,10 +11,12 @@ This guide covers three deployment methods:
|
||||
## Prerequisites
|
||||
|
||||
### All Methods
|
||||
|
||||
- Git
|
||||
- A PostgreSQL 16+ database (or use the included Docker Compose)
|
||||
|
||||
### Development
|
||||
|
||||
- **Node.js** 20+ ([nodejs.org](https://nodejs.org))
|
||||
- **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`)
|
||||
- **Rust** 1.83+ ([rustup.rs](https://rustup.rs))
|
||||
@@ -21,6 +24,7 @@ This guide covers three deployment methods:
|
||||
- **Docker** — for running PostgreSQL and Redis locally
|
||||
|
||||
### Docker Production
|
||||
|
||||
- **Docker** 24+ with Docker Compose v2
|
||||
- At least **2 GB RAM** and **10 GB disk** for the panel itself
|
||||
- Additional resources for game servers on daemon nodes
|
||||
@@ -77,6 +81,7 @@ All three steps are idempotent, so re-running them after a `git pull` is the
|
||||
normal way to pick up schema and default-game changes.
|
||||
|
||||
After seeding, you'll have:
|
||||
|
||||
- **Admin account**: `admin@gamepanel.local` / `admin123`
|
||||
- **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory,
|
||||
FiveM, ARK: Survival Evolved
|
||||
@@ -155,14 +160,14 @@ Then open `http://<server-ip>:80` and sign in with
|
||||
|
||||
### 2.2 What gets started
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `postgres` | internal | PostgreSQL database |
|
||||
| `redis` | internal | Rate limiting & cache |
|
||||
| `migrate` | — | Applies the schema + seed, then exits |
|
||||
| `api` | internal | Fastify REST API |
|
||||
| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
|
||||
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
|
||||
| Service | Port | Description |
|
||||
| ---------- | -------------------------- | -------------------------------------------------- |
|
||||
| `postgres` | internal | PostgreSQL database |
|
||||
| `redis` | internal | Rate limiting & cache |
|
||||
| `migrate` | — | Applies the schema + seed, then exits |
|
||||
| `api` | internal | Fastify REST API |
|
||||
| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
|
||||
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
|
||||
|
||||
Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the
|
||||
internal Compose network.
|
||||
@@ -174,11 +179,11 @@ The `migrate` service runs on every `docker compose up`; all three of its steps
|
||||
|
||||
In the panel, create a node with:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| FQDN | `host.docker.internal` (or the host's IP/hostname) |
|
||||
| gRPC port | the `DAEMON_GRPC_PORT` from `.env` |
|
||||
| Daemon token | the `DAEMON_TOKEN` from `.env` |
|
||||
| Field | Value |
|
||||
| ------------ | -------------------------------------------------- |
|
||||
| FQDN | `host.docker.internal` (or the host's IP/hostname) |
|
||||
| gRPC port | the `DAEMON_GRPC_PORT` from `.env` |
|
||||
| Daemon token | the `DAEMON_TOKEN` from `.env` |
|
||||
|
||||
### 2.4 Where game server files live
|
||||
|
||||
@@ -550,12 +555,12 @@ but nothing in it is panel-specific.
|
||||
`.github/workflows/ci.yml` pushes four images to this Gitea instance's own
|
||||
container registry on every `v*` tag:
|
||||
|
||||
| Image | Contents |
|
||||
|---|---|
|
||||
| `gamepanel-api` | Fastify API |
|
||||
| Image | Contents |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `gamepanel-api` | Fastify API |
|
||||
| `gamepanel-migrate` | The API Dockerfile's `migrate` stage, run once before the API starts |
|
||||
| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` |
|
||||
| `gamepanel-daemon` | Rust daemon |
|
||||
| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` |
|
||||
| `gamepanel-daemon` | Rust daemon |
|
||||
|
||||
Add a `REGISTRY_TOKEN` repository secret with package write scope, then:
|
||||
|
||||
@@ -578,14 +583,14 @@ the node. If the panel has a file manager, both steps can be done from it.
|
||||
|
||||
Paste `docker-compose.panel.yml` into the panel's custom-compose screen and set:
|
||||
|
||||
| Variable | Example | Notes |
|
||||
|---|---|---|
|
||||
| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images |
|
||||
| `TAG` | `v0.1.0` | The tag you pushed |
|
||||
| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it |
|
||||
| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | |
|
||||
| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | |
|
||||
| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses |
|
||||
| Variable | Example | Notes |
|
||||
| ---------------------------------- | --------------------------- | ------------------------------------------------ |
|
||||
| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images |
|
||||
| `TAG` | `v0.1.0` | The tag you pushed |
|
||||
| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it |
|
||||
| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | |
|
||||
| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | |
|
||||
| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses |
|
||||
|
||||
The published port is called `HOST_PORT` because panels commonly reverse-proxy
|
||||
"the" port of an installation and need to know which one that is when a stack
|
||||
@@ -657,25 +662,30 @@ Then add the node in the panel with the remote machine's FQDN.
|
||||
## Troubleshooting
|
||||
|
||||
### API won't start
|
||||
|
||||
- Check `DATABASE_URL` is correct and PostgreSQL is running
|
||||
- Ensure migrations have been applied: `pnpm db:migrate`
|
||||
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
|
||||
|
||||
### Daemon can't connect
|
||||
|
||||
- Verify `api_url` in daemon config points to the API
|
||||
- Check `node_token` matches what's stored in the panel's nodes table
|
||||
- Ensure the daemon's gRPC port (50051) is open
|
||||
|
||||
### Web shows blank page
|
||||
|
||||
- Build the SPA: `pnpm --filter @source/web build`
|
||||
- Check nginx config: `sudo nginx -t`
|
||||
- Verify API proxy is working: `curl http://localhost:3000/api/health`
|
||||
|
||||
### Docker permission denied
|
||||
|
||||
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
|
||||
- Or run the daemon with appropriate privileges
|
||||
|
||||
### protoc not found (daemon build)
|
||||
|
||||
- Ubuntu: `sudo apt install protobuf-compiler`
|
||||
- macOS: `brew install protobuf`
|
||||
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
|
||||
@@ -718,24 +728,24 @@ sudo systemctl reload nginx
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
||||
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
||||
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
||||
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
||||
| `REDIS_URL` | — | Redis connection string |
|
||||
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
||||
| `PORT` | `3000` | API listen port |
|
||||
| `HOST` | `0.0.0.0` | API listen host |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
||||
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
||||
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
||||
| `WEB_PORT` | `80` | Web nginx exposed port |
|
||||
| `API_PORT` | `3000` | API exposed port (Docker) |
|
||||
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
||||
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | --------------------------- | --------------------------------------- |
|
||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
||||
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
||||
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
||||
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
||||
| `REDIS_URL` | — | Redis connection string |
|
||||
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
||||
| `PORT` | `3000` | API listen port |
|
||||
| `HOST` | `0.0.0.0` | API listen host |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
||||
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
||||
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
||||
| `WEB_PORT` | `80` | Web nginx exposed port |
|
||||
| `API_PORT` | `3000` | API exposed port (Docker) |
|
||||
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
||||
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
||||
|
||||
@@ -7,6 +7,7 @@ Modern, open-source game server management panel built with a multi-tenant SaaS
|
||||
## Features
|
||||
|
||||
### Core
|
||||
|
||||
- **Multi-Tenant Organizations** — Isolated environments with role-based access control (Admin / User + custom JSONB permissions)
|
||||
- **Docker Container Management** — Full lifecycle: create, start, stop, restart, kill, delete
|
||||
- **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring
|
||||
@@ -15,16 +16,19 @@ Modern, open-source game server management panel built with a multi-tenant SaaS
|
||||
- **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources
|
||||
|
||||
### Game-Specific
|
||||
|
||||
- **Config Editor** — Tab-based UI with parsers for `.properties`, `.json`, `.yaml`, and Source Engine `.cfg` formats
|
||||
- **Plugin Management** — Spiget API integration for Minecraft, manual install for other games, toggle/uninstall
|
||||
- **Player Tracking** — Live player list via RCON protocol (Minecraft `list`, CS2 `status`)
|
||||
|
||||
### Advanced
|
||||
|
||||
- **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support
|
||||
- **Backup System** — Create, restore, lock/unlock, delete backups with CDN storage integration
|
||||
- **Audit Logging** — Track all actions across the panel with user, server, and IP metadata
|
||||
|
||||
### Operations
|
||||
|
||||
- **Rate Limiting** — Configurable per-window request limits
|
||||
- **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention
|
||||
- **Health Checks** — Built-in endpoints for all services
|
||||
@@ -56,18 +60,18 @@ The API acts as a **gateway** between the frontend and daemon nodes. The fronten
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Monorepo | Turborepo + pnpm |
|
||||
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
|
||||
| Backend API | Fastify 5 + TypeBox validation |
|
||||
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
|
||||
| Database | PostgreSQL 16 + Drizzle ORM |
|
||||
| Auth | JWT (access + refresh) + Argon2id |
|
||||
| Realtime | Socket.IO (frontend ↔ API) |
|
||||
| Panel ↔ Daemon | gRPC with protobuf |
|
||||
| Containers | Docker |
|
||||
| CI/CD | GitHub Actions |
|
||||
| Component | Technology |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| Monorepo | Turborepo + pnpm |
|
||||
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
|
||||
| Backend API | Fastify 5 + TypeBox validation |
|
||||
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
|
||||
| Database | PostgreSQL 16 + Drizzle ORM |
|
||||
| Auth | JWT (access + refresh) + Argon2id |
|
||||
| Realtime | Socket.IO (frontend ↔ API) |
|
||||
| Panel ↔ Daemon | gRPC with protobuf |
|
||||
| Containers | Docker |
|
||||
| CI/CD | GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
@@ -150,16 +154,16 @@ source-gamepanel/
|
||||
|
||||
## Supported Games
|
||||
|
||||
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
||||
|------|-------------|-------------|---------------|---------------|
|
||||
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
||||
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
||||
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
||||
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
||||
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
||||
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
|
||||
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
|
||||
| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — |
|
||||
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
||||
| -------------------------- | ------------------------------- | ------------------------------------------- | ---------------------------------- | ------------------- |
|
||||
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
||||
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
||||
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
||||
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
||||
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
||||
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
|
||||
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
|
||||
| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — |
|
||||
|
||||
Most games only need a database seed entry: the container mount point, the
|
||||
in-game stop command and the shutdown budget are all columns on `games`, so no
|
||||
@@ -171,40 +175,44 @@ daemon change is required for a new image. Games whose process ignores stdin
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/auth/register` | Create account |
|
||||
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Invalidate session |
|
||||
| GET | `/api/auth/me` | Current user profile |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | -------------------- | ------------------------------------ |
|
||||
| POST | `/api/auth/register` | Create account |
|
||||
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Invalidate session |
|
||||
| GET | `/api/auth/me` | Current user profile |
|
||||
|
||||
### Organizations
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/organizations` | List user's orgs |
|
||||
| POST | `/api/organizations` | Create org |
|
||||
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
|
||||
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ---------------- | ----------------------------------- | ----------------- |
|
||||
| GET | `/api/organizations` | List user's orgs |
|
||||
| POST | `/api/organizations` | Create org |
|
||||
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
|
||||
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
|
||||
|
||||
### Servers
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET/POST | `.../servers` | List / create |
|
||||
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
|
||||
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
|
||||
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
|
||||
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
|
||||
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
|
||||
|
||||
| Method | Path | Description |
|
||||
| --------------------- | ------------------------------------------- | --------------------------------------- |
|
||||
| GET/POST | `.../servers` | List / create |
|
||||
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
|
||||
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
|
||||
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
|
||||
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
|
||||
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
|
||||
|
||||
### Admin (Super Admin only)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/admin/users` | All users |
|
||||
| GET/POST | `/api/admin/games` | Game management |
|
||||
| GET | `/api/admin/audit-logs` | Audit trail |
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------- | --------------- |
|
||||
| GET | `/api/admin/users` | All users |
|
||||
| GET/POST | `/api/admin/games` | Game management |
|
||||
| GET | `/api/admin/audit-logs` | Audit trail |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+36
-14
@@ -7,12 +7,19 @@ FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/database/package.json packages/database/
|
||||
COPY packages/proto/package.json packages/proto/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
# pnpm creates no node_modules for a workspace package that has no
|
||||
# dependencies of its own, and @source/shared has none. The COPY lines below
|
||||
# name that path, so give them an empty directory to find instead of failing
|
||||
# the build on a path pnpm never made.
|
||||
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS build
|
||||
# --- Type check ---
|
||||
# Not an artifact producer: tsconfig.base.json sets noEmit, so this stage only
|
||||
# proves the sources compile. The runtime stages below run TypeScript directly.
|
||||
FROM base AS typecheck
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
@@ -40,25 +47,40 @@ WORKDIR /app/packages/database
|
||||
CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"]
|
||||
|
||||
# --- Production ---
|
||||
FROM node:20-alpine AS production
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
#
|
||||
# Runs the TypeScript sources through tsx rather than a compiled bundle, the
|
||||
# same way the migrate stage above already does.
|
||||
#
|
||||
# The workspace packages are consumed as TypeScript: every @source/* package
|
||||
# points `main` at ./src/index.ts, which is what lets `pnpm dev` and Vite read
|
||||
# them without a build step. A compiled entry point would resolve those bare
|
||||
# imports to TypeScript files Node cannot load, and @source/proto derives the
|
||||
# path of daemon.proto from its own module URL — a rule written for
|
||||
# `/src/index.ts`. Following that decision here keeps one resolution model for
|
||||
# development and production instead of two that disagree.
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/
|
||||
COPY --from=build /app/packages/database/dist ./packages/database/dist
|
||||
COPY --from=build /app/packages/database/package.json ./packages/database/
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/database ./packages/database
|
||||
COPY packages/proto ./packages/proto
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
# 127.0.0.1, not localhost: musl resolves localhost to ::1 first and the
|
||||
# server binds IPv4, so the probe was refused on every run and the
|
||||
# container never left the unhealthy state.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
# From the package directory, the same way `pnpm dev` runs it: pnpm's isolated
|
||||
# node_modules puts tsx in apps/api/node_modules/.bin, not in the workspace
|
||||
# root, so `pnpm exec` only finds it here.
|
||||
WORKDIR /app/apps/api
|
||||
CMD ["pnpm", "exec", "tsx", "src/index.ts"]
|
||||
|
||||
+36
-34
@@ -18,10 +18,7 @@ import { AppError } from './lib/errors.js';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined,
|
||||
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,39 +43,44 @@ await app.register(authPlugin);
|
||||
await app.register(socketPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
app.setErrorHandler(
|
||||
(
|
||||
error: Error & { validation?: unknown; statusCode?: number; code?: string },
|
||||
_request,
|
||||
reply,
|
||||
) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
|
||||
// Fastify validation errors
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
// Fastify validation errors
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (error.statusCode === 429) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded, please try again later',
|
||||
});
|
||||
}
|
||||
// Rate limit errors
|
||||
if (error.statusCode === 429) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded, please try again later',
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(error.statusCode ?? 500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'An unexpected error occurred'
|
||||
: error.message,
|
||||
});
|
||||
});
|
||||
app.log.error(error);
|
||||
return reply.code(error.statusCode ?? 500).send({
|
||||
error: 'Internal Server Error',
|
||||
message:
|
||||
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', async () => {
|
||||
|
||||
+5
-11
@@ -23,7 +23,9 @@ function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
||||
}
|
||||
|
||||
function getArtifactAccessTtlSeconds(): number {
|
||||
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
|
||||
const raw = Number(
|
||||
process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS,
|
||||
);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
||||
return Math.floor(raw);
|
||||
}
|
||||
@@ -100,11 +102,7 @@ export async function ensurePrivatePluginBucket(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to fetch CDN plugin bucket',
|
||||
'CDN_BUCKET_READ_FAILED',
|
||||
);
|
||||
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +190,6 @@ export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<s
|
||||
|
||||
return new URL(resolvedUrl, config.baseUrl).toString();
|
||||
} catch (error) {
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to get temporary CDN access URL',
|
||||
'CDN_ACCESS_URL_FAILED',
|
||||
);
|
||||
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,15 +662,17 @@ export async function daemonSetPowerState(
|
||||
action: PowerAction,
|
||||
options: DaemonPowerOptions = {},
|
||||
): Promise<void> {
|
||||
const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0
|
||||
? Math.floor(Number(options.stopTimeoutSeconds))
|
||||
: 0;
|
||||
const stopTimeoutSeconds =
|
||||
Number(options.stopTimeoutSeconds) > 0 ? Math.floor(Number(options.stopTimeoutSeconds)) : 0;
|
||||
|
||||
// The daemon waits out the shutdown before replying, so the RPC deadline has
|
||||
// to outlive the game's own budget (ARK saves its world for minutes).
|
||||
const rpcTimeoutMs =
|
||||
action === 'stop' || action === 'restart'
|
||||
? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS)
|
||||
? Math.min(
|
||||
Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS),
|
||||
MAX_POWER_RPC_TIMEOUT_MS,
|
||||
)
|
||||
: POWER_RPC_TIMEOUT_MS;
|
||||
|
||||
const client = createClient(node);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from './daemon.js';
|
||||
|
||||
/**
|
||||
* Some game images run a SteamCMD `app_update ... validate` on every container
|
||||
@@ -168,21 +164,14 @@ export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[]
|
||||
}
|
||||
|
||||
/** The managed file a request path refers to, or `null` if it is not managed. */
|
||||
export function managedConfigFileFor(
|
||||
gameSlug: string,
|
||||
path: string,
|
||||
): ManagedConfigFile | null {
|
||||
export function managedConfigFileFor(gameSlug: string, path: string): ManagedConfigFile | null {
|
||||
const normalized = normalizePath(path);
|
||||
return (
|
||||
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
|
||||
);
|
||||
return managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null;
|
||||
}
|
||||
|
||||
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
|
||||
const normalized = fileName.trim();
|
||||
return managedConfigFilesForGame(gameSlug).some(
|
||||
(file) => file.shadowFileName === normalized,
|
||||
);
|
||||
return managedConfigFilesForGame(gameSlug).some((file) => file.shadowFileName === normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,10 +360,7 @@ export function sustainManagedConfigsAfterStart(
|
||||
|
||||
stableRounds = drifted ? 0 : stableRounds + 1;
|
||||
|
||||
if (
|
||||
stableRounds >= REQUIRED_STABLE_ROUNDS &&
|
||||
Date.now() - startedAt >= MIN_WATCH_MS
|
||||
) {
|
||||
if (stableRounds >= REQUIRED_STABLE_ROUNDS && Date.now() - startedAt >= MIN_WATCH_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function getOrgMembership(
|
||||
* Check if the user has a specific permission in the organization.
|
||||
* Super admins always have all permissions.
|
||||
*/
|
||||
export function hasPermission(membership: OrgMember | 'super_admin', permission: Permission): boolean {
|
||||
export function hasPermission(
|
||||
membership: OrgMember | 'super_admin',
|
||||
permission: Permission,
|
||||
): boolean {
|
||||
if (membership === 'super_admin') return true;
|
||||
|
||||
// Check custom permission overrides first
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Compute the next run time for a scheduled task.
|
||||
*/
|
||||
export function computeNextRun(
|
||||
scheduleType: string,
|
||||
scheduleData: Record<string, unknown>,
|
||||
): Date {
|
||||
export function computeNextRun(scheduleType: string, scheduleData: Record<string, unknown>): Date {
|
||||
const now = new Date();
|
||||
|
||||
switch (scheduleType) {
|
||||
|
||||
@@ -171,10 +171,7 @@ function readWorkflowId(value: unknown): string | null {
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeWorkflow(
|
||||
gameSlug: string,
|
||||
workflow: GameAutomationRule,
|
||||
): GameAutomationRule {
|
||||
function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule {
|
||||
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||
|
||||
if (workflow.id === 'cs2-write-default-server-config') {
|
||||
@@ -237,9 +234,7 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
|
||||
}
|
||||
|
||||
const existingIds = new Set(
|
||||
raw
|
||||
.map(readWorkflowId)
|
||||
.filter((workflowId): workflowId is string => workflowId !== null),
|
||||
raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null),
|
||||
);
|
||||
|
||||
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
||||
@@ -247,7 +242,9 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
|
||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
return [...configured, ...missingDefaults].map((workflow) =>
|
||||
normalizeWorkflow(gameSlug, workflow),
|
||||
);
|
||||
}
|
||||
|
||||
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
||||
@@ -386,9 +383,7 @@ interface DirectoryAssetCandidate {
|
||||
function extractNumberParts(value: string): number[] {
|
||||
const matches = value.match(/\d+/g);
|
||||
if (!matches) return [];
|
||||
return matches
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((num) => Number.isFinite(num));
|
||||
return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num));
|
||||
}
|
||||
|
||||
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
||||
@@ -431,7 +426,9 @@ function extractDirectoryCandidates(
|
||||
|
||||
try {
|
||||
const resolvedUrl = new URL(href, indexUrl);
|
||||
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
|
||||
const filename = decodeURIComponent(
|
||||
resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '',
|
||||
);
|
||||
if (!filename || !assetPattern.test(filename)) continue;
|
||||
|
||||
candidates.push({
|
||||
@@ -611,7 +608,8 @@ async function executeGitHubReleaseExtract(
|
||||
);
|
||||
}
|
||||
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const maxBytes =
|
||||
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
@@ -651,7 +649,8 @@ async function executeHttpDirectoryExtract(
|
||||
action: ServerAutomationHttpDirectoryExtractAction,
|
||||
): Promise<void> {
|
||||
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const maxBytes =
|
||||
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
@@ -701,9 +700,7 @@ async function executeInsertBeforeLine(
|
||||
|
||||
const skipIfExists = action.skipIfExists !== false;
|
||||
if (skipIfExists) {
|
||||
const existsRegex = action.existsPattern
|
||||
? new RegExp(action.existsPattern, 'i')
|
||||
: null;
|
||||
const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null;
|
||||
|
||||
const alreadyExists = lines.some((line) =>
|
||||
existsRegex ? existsRegex.test(line) : line === action.line,
|
||||
@@ -777,9 +774,7 @@ async function executeAction(
|
||||
|
||||
case 'write_file': {
|
||||
const payload =
|
||||
action.encoding === 'base64'
|
||||
? Buffer.from(action.data, 'base64')
|
||||
: action.data;
|
||||
action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data;
|
||||
|
||||
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
||||
app.log.info(
|
||||
@@ -847,7 +842,7 @@ export async function runServerAutomationEvent(
|
||||
if (
|
||||
runOnce &&
|
||||
!context.force &&
|
||||
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
|
||||
(await hasMarker(context.node, context.serverUuid, context.event, workflow.id))
|
||||
) {
|
||||
result.workflowsSkipped += 1;
|
||||
app.log.info(
|
||||
|
||||
@@ -18,7 +18,8 @@ export default fp(async (app: FastifyInstance) => {
|
||||
const db = createDb(databaseUrl);
|
||||
app.decorate('db', db);
|
||||
|
||||
await db.execute(sql.raw(`
|
||||
await db.execute(
|
||||
sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS server_databases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
@@ -32,7 +33,8 @@ export default fp(async (app: FastifyInstance) => {
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`));
|
||||
`),
|
||||
);
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
||||
|
||||
@@ -59,9 +59,8 @@ export default fp(async (app: FastifyInstance) => {
|
||||
};
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = typeof socket.handshake.auth?.token === 'string'
|
||||
? socket.handshake.auth.token
|
||||
: null;
|
||||
const token =
|
||||
typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null;
|
||||
|
||||
if (!token) {
|
||||
next(new Error('Unauthorized'));
|
||||
@@ -102,9 +101,10 @@ export default fp(async (app: FastifyInstance) => {
|
||||
};
|
||||
|
||||
socket.on('server:console:join', async (payload: unknown) => {
|
||||
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
||||
? ((payload as { serverId: string }).serverId)
|
||||
: '';
|
||||
const serverId =
|
||||
typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
||||
? (payload as { serverId: string }).serverId
|
||||
: '';
|
||||
if (!serverId) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid server id' });
|
||||
return;
|
||||
@@ -202,9 +202,8 @@ export default fp(async (app: FastifyInstance) => {
|
||||
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
||||
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
||||
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
||||
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
|
||||
? body.requestId.trim()
|
||||
: null;
|
||||
const requestId =
|
||||
typeof body.requestId === 'string' && body.requestId.trim() ? body.requestId.trim() : null;
|
||||
|
||||
if (!serverId || !orgId || !command) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
||||
|
||||
+273
-253
@@ -86,10 +86,7 @@ function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonArrayUploadFile(
|
||||
file: UploadJsonFile | null,
|
||||
fieldName: string,
|
||||
): unknown[] {
|
||||
function parseJsonArrayUploadFile(file: UploadJsonFile | null, fieldName: string): unknown[] {
|
||||
if (!file) return [];
|
||||
|
||||
let rawValue = file.data.toString('utf8');
|
||||
@@ -115,8 +112,10 @@ function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
||||
if (typeof rawValue !== 'string') return undefined;
|
||||
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on')
|
||||
return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off')
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -124,7 +123,8 @@ function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
||||
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
||||
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable')
|
||||
return normalized;
|
||||
}
|
||||
return 'stable';
|
||||
}
|
||||
@@ -228,10 +228,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
|
||||
// GET /api/admin/games
|
||||
app.get('/games', async () => {
|
||||
const gameList = await app.db
|
||||
.select()
|
||||
.from(games)
|
||||
.orderBy(games.name);
|
||||
const gameList = await app.db.select().from(games).orderBy(games.name);
|
||||
|
||||
return { data: gameList };
|
||||
});
|
||||
@@ -271,20 +268,24 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// PATCH /api/admin/games/:gameId
|
||||
app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => {
|
||||
const { gameId } = request.params as { gameId: string };
|
||||
const body = request.body as Record<string, unknown>;
|
||||
app.patch(
|
||||
'/games/:gameId',
|
||||
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
|
||||
async (request) => {
|
||||
const { gameId } = request.params as { gameId: string };
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(games)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(games.id, gameId))
|
||||
.returning();
|
||||
const [updated] = await app.db
|
||||
.update(games)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(games.id, gameId))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Game not found');
|
||||
if (!updated) throw AppError.notFound('Game not found');
|
||||
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// === Nodes (global view) ===
|
||||
|
||||
@@ -581,49 +582,56 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
source?: 'manual' | 'spiget';
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
app.patch(
|
||||
'/plugins/:pluginId',
|
||||
{ schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } },
|
||||
async (request) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
source?: 'manual' | 'spiget';
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Plugin not found');
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Plugin not found');
|
||||
|
||||
const nextSlug = body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
||||
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
|
||||
const nextSlug =
|
||||
body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: body.name !== undefined
|
||||
? toSlug(body.name)
|
||||
: existing.slug;
|
||||
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
|
||||
|
||||
const duplicate = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
||||
});
|
||||
if (duplicate && duplicate.id !== existing.id) {
|
||||
throw AppError.conflict('Plugin slug already exists for this game');
|
||||
}
|
||||
const duplicate = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
||||
});
|
||||
if (duplicate && duplicate.id !== existing.id) {
|
||||
throw AppError.conflict('Plugin slug already exists for this game');
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(plugins)
|
||||
.set({
|
||||
name: body.name ?? existing.name,
|
||||
slug: nextSlug,
|
||||
description: body.description ?? existing.description,
|
||||
source: body.source ?? existing.source,
|
||||
isGlobal: body.isGlobal ?? existing.isGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning();
|
||||
const [updated] = await app.db
|
||||
.update(plugins)
|
||||
.set({
|
||||
name: body.name ?? existing.name,
|
||||
slug: nextSlug,
|
||||
description: body.description ?? existing.description,
|
||||
source: body.source ?? existing.source,
|
||||
isGlobal: body.isGlobal ?? existing.isGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Plugin not found');
|
||||
return updated;
|
||||
});
|
||||
if (!updated) throw AppError.notFound('Plugin not found');
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
@@ -642,216 +650,231 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
return { plugin, releases };
|
||||
});
|
||||
|
||||
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
app.post(
|
||||
'/plugins/:pluginId/releases/upload',
|
||||
{ schema: PluginIdParamSchema },
|
||||
async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
||||
}
|
||||
if (!request.isMultipart()) {
|
||||
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
const files: UploadArtifactFile[] = [];
|
||||
let installSchemaFile: UploadJsonFile | null = null;
|
||||
let configTemplatesFile: UploadJsonFile | null = null;
|
||||
const relativePathQueue: string[] = [];
|
||||
const fields: Record<string, unknown> = {};
|
||||
const files: UploadArtifactFile[] = [];
|
||||
let installSchemaFile: UploadJsonFile | null = null;
|
||||
let configTemplatesFile: UploadJsonFile | null = null;
|
||||
const relativePathQueue: string[] = [];
|
||||
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === 'file') {
|
||||
if (part.fieldname === 'installSchemaFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
installSchemaFile = {
|
||||
filename: part.filename || 'install-schema.json',
|
||||
data,
|
||||
};
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === 'file') {
|
||||
if (part.fieldname === 'installSchemaFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
installSchemaFile = {
|
||||
filename: part.filename || 'install-schema.json',
|
||||
data,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.fieldname === 'configTemplatesFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
configTemplatesFile = {
|
||||
filename: part.filename || 'config-templates.json',
|
||||
data,
|
||||
};
|
||||
if (part.fieldname === 'configTemplatesFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
configTemplatesFile = {
|
||||
filename: part.filename || 'config-templates.json',
|
||||
data,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackName = `artifact-${files.length + 1}.bin`;
|
||||
const queuedPath = relativePathQueue.shift();
|
||||
const relativePath = normalizeRelativePath(
|
||||
queuedPath ?? part.filename ?? '',
|
||||
fallbackName,
|
||||
);
|
||||
const data = await part.toBuffer();
|
||||
if (data.length === 0) continue;
|
||||
files.push({ relativePath, data });
|
||||
const fallbackName = `artifact-${files.length + 1}.bin`;
|
||||
const queuedPath = relativePathQueue.shift();
|
||||
const relativePath = normalizeRelativePath(
|
||||
queuedPath ?? part.filename ?? '',
|
||||
fallbackName,
|
||||
);
|
||||
const data = await part.toBuffer();
|
||||
if (data.length === 0) continue;
|
||||
files.push({ relativePath, data });
|
||||
} else {
|
||||
if (part.fieldname === 'relativePath') {
|
||||
const raw = typeof part.value === 'string' ? part.value : '';
|
||||
relativePathQueue.push(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw AppError.badRequest('At least one file is required');
|
||||
}
|
||||
|
||||
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
|
||||
if (!version) {
|
||||
throw AppError.badRequest('version is required');
|
||||
}
|
||||
|
||||
const channel = parseReleaseChannel(fields.channel);
|
||||
const destination =
|
||||
typeof fields.destination === 'string' && fields.destination.trim().length > 0
|
||||
? fields.destination.trim()
|
||||
: null;
|
||||
const changelog =
|
||||
typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
|
||||
? fields.changelog
|
||||
: null;
|
||||
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
|
||||
const installSchema = parseJsonArrayInput(
|
||||
fields.installSchema,
|
||||
installSchemaFile,
|
||||
'installSchema',
|
||||
);
|
||||
const configTemplates = parseJsonArrayInput(
|
||||
fields.configTemplates,
|
||||
configTemplatesFile,
|
||||
'configTemplates',
|
||||
);
|
||||
|
||||
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
|
||||
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
|
||||
const shouldZip = files.length > 1 || hasNestedPaths;
|
||||
|
||||
let artifactType: 'file' | 'zip';
|
||||
let artifactContent: Buffer;
|
||||
let uploadFileName: string;
|
||||
let releaseFileName: string | null;
|
||||
|
||||
if (shouldZip) {
|
||||
artifactType = 'zip';
|
||||
artifactContent = await zipArtifacts(files);
|
||||
|
||||
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
|
||||
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
|
||||
? suggestedName
|
||||
: `${suggestedName}.zip`;
|
||||
releaseFileName = null;
|
||||
} else {
|
||||
if (part.fieldname === 'relativePath') {
|
||||
const raw = typeof part.value === 'string' ? part.value : '';
|
||||
relativePathQueue.push(raw);
|
||||
continue;
|
||||
artifactType = 'file';
|
||||
const [singleFile] = files;
|
||||
if (!singleFile) {
|
||||
throw AppError.badRequest('No artifact file received');
|
||||
}
|
||||
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw AppError.badRequest('At least one file is required');
|
||||
}
|
||||
|
||||
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
|
||||
if (!version) {
|
||||
throw AppError.badRequest('version is required');
|
||||
}
|
||||
|
||||
const channel = parseReleaseChannel(fields.channel);
|
||||
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0
|
||||
? fields.destination.trim()
|
||||
: null;
|
||||
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
|
||||
? fields.changelog
|
||||
: null;
|
||||
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
|
||||
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema');
|
||||
const configTemplates = parseJsonArrayInput(
|
||||
fields.configTemplates,
|
||||
configTemplatesFile,
|
||||
'configTemplates',
|
||||
);
|
||||
|
||||
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
|
||||
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
|
||||
const shouldZip = files.length > 1 || hasNestedPaths;
|
||||
|
||||
let artifactType: 'file' | 'zip';
|
||||
let artifactContent: Buffer;
|
||||
let uploadFileName: string;
|
||||
let releaseFileName: string | null;
|
||||
|
||||
if (shouldZip) {
|
||||
artifactType = 'zip';
|
||||
artifactContent = await zipArtifacts(files);
|
||||
|
||||
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
|
||||
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
|
||||
? suggestedName
|
||||
: `${suggestedName}.zip`;
|
||||
releaseFileName = null;
|
||||
} else {
|
||||
artifactType = 'file';
|
||||
const [singleFile] = files;
|
||||
if (!singleFile) {
|
||||
throw AppError.badRequest('No artifact file received');
|
||||
artifactContent = singleFile.data;
|
||||
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
||||
uploadFileName = rawFileName || originalName;
|
||||
releaseFileName = uploadFileName;
|
||||
}
|
||||
|
||||
artifactContent = singleFile.data;
|
||||
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
||||
uploadFileName = rawFileName || originalName;
|
||||
releaseFileName = uploadFileName;
|
||||
}
|
||||
|
||||
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
||||
pluginId: plugin.id,
|
||||
pluginSlug: plugin.slug,
|
||||
releaseVersion: version,
|
||||
uploadedBy: request.user.sub,
|
||||
uploadMode: shouldZip ? 'archive' : 'single',
|
||||
sourceFileCount: files.length,
|
||||
});
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
||||
pluginId: plugin.id,
|
||||
version,
|
||||
channel,
|
||||
artifactType,
|
||||
artifactUrl: uploaded.artifactPointer,
|
||||
destination,
|
||||
fileName: releaseFileName,
|
||||
changelog,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
isPublished,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
pluginSlug: plugin.slug,
|
||||
releaseVersion: version,
|
||||
uploadedBy: request.user.sub,
|
||||
uploadMode: shouldZip ? 'archive' : 'single',
|
||||
sourceFileCount: files.length,
|
||||
});
|
||||
|
||||
return reply.code(201).send({
|
||||
release: created,
|
||||
artifact: {
|
||||
bucket: uploaded.bucket,
|
||||
fileId: uploaded.file.id,
|
||||
storedName: uploaded.file.storedName,
|
||||
originalName: uploaded.file.originalName,
|
||||
pointer: uploaded.artifactPointer,
|
||||
},
|
||||
});
|
||||
});
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId: plugin.id,
|
||||
version,
|
||||
channel,
|
||||
artifactType,
|
||||
artifactUrl: uploaded.artifactPointer,
|
||||
destination,
|
||||
fileName: releaseFileName,
|
||||
changelog,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
isPublished,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
version: string;
|
||||
channel?: 'stable' | 'beta' | 'alpha';
|
||||
artifactType?: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
isPublished?: boolean;
|
||||
cloneFromReleaseId?: string;
|
||||
};
|
||||
return reply.code(201).send({
|
||||
release: created,
|
||||
artifact: {
|
||||
bucket: uploaded.bucket,
|
||||
fileId: uploaded.file.id,
|
||||
storedName: uploaded.file.storedName,
|
||||
originalName: uploaded.file.originalName,
|
||||
pointer: uploaded.artifactPointer,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
app.post(
|
||||
'/plugins/:pluginId/releases',
|
||||
{ schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } },
|
||||
async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
version: string;
|
||||
channel?: 'stable' | 'beta' | 'alpha';
|
||||
artifactType?: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
isPublished?: boolean;
|
||||
cloneFromReleaseId?: string;
|
||||
};
|
||||
|
||||
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
||||
if (body.cloneFromReleaseId) {
|
||||
baseRelease = await app.db.query.pluginReleases.findFirst({
|
||||
where: and(
|
||||
eq(pluginReleases.id, body.cloneFromReleaseId),
|
||||
eq(pluginReleases.pluginId, pluginId),
|
||||
),
|
||||
}) ?? null;
|
||||
if (!baseRelease) {
|
||||
throw AppError.notFound('Clone source release not found');
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
|
||||
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
||||
if (body.cloneFromReleaseId) {
|
||||
baseRelease =
|
||||
(await app.db.query.pluginReleases.findFirst({
|
||||
where: and(
|
||||
eq(pluginReleases.id, body.cloneFromReleaseId),
|
||||
eq(pluginReleases.pluginId, pluginId),
|
||||
),
|
||||
})) ?? null;
|
||||
if (!baseRelease) {
|
||||
throw AppError.notFound('Clone source release not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId,
|
||||
version: body.version,
|
||||
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
||||
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
||||
artifactUrl: body.artifactUrl,
|
||||
destination: body.destination ?? baseRelease?.destination ?? null,
|
||||
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
||||
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
||||
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
||||
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
||||
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId,
|
||||
version: body.version,
|
||||
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
||||
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
||||
artifactUrl: body.artifactUrl,
|
||||
destination: body.destination ?? baseRelease?.destination ?? null,
|
||||
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
||||
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
||||
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
||||
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
||||
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
return reply.code(201).send(created);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/plugins/:pluginId/releases/:releaseId',
|
||||
@@ -901,10 +924,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
|
||||
// GET /api/admin/nodes
|
||||
app.get('/nodes', async () => {
|
||||
const nodeList = await app.db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.orderBy(nodes.createdAt);
|
||||
const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt);
|
||||
|
||||
return { data: nodeList };
|
||||
});
|
||||
|
||||
@@ -90,10 +90,14 @@ export const ReleaseInstallFieldSchema = Type.Object({
|
||||
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
defaultValue: Type.Optional(Type.Any()),
|
||||
options: Type.Optional(Type.Array(Type.Object({
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
}))),
|
||||
options: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
min: Type.Optional(Type.Number()),
|
||||
max: Type.Optional(Type.Number()),
|
||||
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
||||
@@ -107,7 +111,9 @@ export const ReleaseTemplateSchema = Type.Object({
|
||||
|
||||
const ImportPluginReleasePayloadSchema = Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
channel: Type.Optional(
|
||||
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||
),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
@@ -138,7 +144,9 @@ export const ImportPluginsSchema = {
|
||||
export const CreatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
channel: Type.Optional(
|
||||
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||
),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
@@ -154,7 +162,9 @@ export const CreatePluginReleaseSchema = {
|
||||
export const UpdatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
channel: Type.Optional(
|
||||
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||
),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
|
||||
@@ -6,10 +6,7 @@ export default async function gameRoutes(app: FastifyInstance) {
|
||||
|
||||
// GET /api/games
|
||||
app.get('/', async () => {
|
||||
const gameList = await app.db
|
||||
.select()
|
||||
.from(games)
|
||||
.orderBy(games.name);
|
||||
const gameList = await app.db.select().from(games).orderBy(games.name);
|
||||
|
||||
return { data: gameList };
|
||||
});
|
||||
|
||||
@@ -18,9 +18,8 @@ function extractCdnWebhookSecret(request: FastifyRequest): string | null {
|
||||
return byHeader.trim();
|
||||
}
|
||||
|
||||
const authHeader = typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined;
|
||||
const authHeader =
|
||||
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined;
|
||||
|
||||
return extractBearerToken(authHeader);
|
||||
}
|
||||
@@ -30,9 +29,7 @@ async function requireDaemonToken(
|
||||
request: FastifyRequest,
|
||||
): Promise<{ id: string }> {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
@@ -69,14 +66,14 @@ export default async function internalRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
const eventType = typeof body?.eventType === 'string'
|
||||
? body.eventType
|
||||
: (typeof body?.type === 'string' ? body.type : 'unknown');
|
||||
const eventType =
|
||||
typeof body?.eventType === 'string'
|
||||
? body.eventType
|
||||
: typeof body?.type === 'string'
|
||||
? body.type
|
||||
: 'unknown';
|
||||
|
||||
request.log.info(
|
||||
{ eventType, payload: body },
|
||||
'Received CDN plugin webhook event',
|
||||
);
|
||||
request.log.info({ eventType, payload: body }, 'Received CDN plugin webhook event');
|
||||
|
||||
return reply.code(202).send({ accepted: true });
|
||||
},
|
||||
@@ -98,11 +95,13 @@ export default async function internalRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(servers.nodeId, node.id),
|
||||
eq(scheduledTasks.isActive, true),
|
||||
lte(scheduledTasks.nextRunAt, now),
|
||||
));
|
||||
.where(
|
||||
and(
|
||||
eq(servers.nodeId, node.id),
|
||||
eq(scheduledTasks.isActive, true),
|
||||
lte(scheduledTasks.nextRunAt, now),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
tasks: dueTasks.map((task) => ({
|
||||
@@ -139,10 +138,7 @@ export default async function internalRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(scheduledTasks.id, taskId),
|
||||
eq(servers.nodeId, node.id),
|
||||
));
|
||||
.where(and(eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id)));
|
||||
|
||||
if (!task) {
|
||||
throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
@@ -23,9 +23,7 @@ export default async function daemonNodeRoutes(app: FastifyInstance) {
|
||||
// POST /api/nodes/heartbeat
|
||||
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
|
||||
@@ -94,28 +94,32 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// PATCH /api/organizations/:orgId/nodes/:nodeId
|
||||
app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
app.patch(
|
||||
'/:nodeId',
|
||||
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
|
||||
async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(nodes)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
|
||||
.returning();
|
||||
const [updated] = await app.db
|
||||
.update(nodes)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Node not found');
|
||||
if (!updated) throw AppError.notFound('Node not found');
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'node.update',
|
||||
metadata: { nodeId, ...body },
|
||||
});
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'node.update',
|
||||
metadata: { nodeId, ...body },
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/organizations/:orgId/nodes/:nodeId
|
||||
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
|
||||
@@ -244,30 +248,34 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
app.post(
|
||||
'/:nodeId/allocations',
|
||||
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
|
||||
async (request, reply) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const { ip, ports } = request.body as { ip: string; ports: number[] };
|
||||
const { ip, ports } = request.body as { ip: string; ports: number[] };
|
||||
|
||||
const values = ports.map((port) => ({
|
||||
nodeId,
|
||||
ip,
|
||||
port,
|
||||
}));
|
||||
const values = ports.map((port) => ({
|
||||
nodeId,
|
||||
ip,
|
||||
port,
|
||||
}));
|
||||
|
||||
const created = await app.db
|
||||
.insert(allocations)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const created = await app.db
|
||||
.insert(allocations)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'allocation.create',
|
||||
metadata: { nodeId, ip, ports },
|
||||
});
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'allocation.create',
|
||||
metadata: { nodeId, ip, ports },
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: created });
|
||||
});
|
||||
return reply.code(201).send({ data: created });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,104 +174,117 @@ export default async function organizationRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/members — invite by email
|
||||
app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => {
|
||||
const { orgId } = request.params as { orgId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
app.post(
|
||||
'/:orgId/members',
|
||||
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
|
||||
async (request, reply) => {
|
||||
const { orgId } = request.params as { orgId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
|
||||
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
|
||||
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (!user) throw AppError.notFound('User with this email not found');
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (!user) throw AppError.notFound('User with this email not found');
|
||||
|
||||
const existing = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
eq(organizationMembers.userId, user.id),
|
||||
),
|
||||
});
|
||||
if (existing) throw AppError.conflict('User is already a member');
|
||||
const existing = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
eq(organizationMembers.userId, user.id),
|
||||
),
|
||||
});
|
||||
if (existing) throw AppError.conflict('User is already a member');
|
||||
|
||||
const [member] = await app.db
|
||||
.insert(organizationMembers)
|
||||
.values({
|
||||
const [member] = await app.db
|
||||
.insert(organizationMembers)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
userId: user.id,
|
||||
role,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
userId: user.id,
|
||||
role,
|
||||
})
|
||||
.returning();
|
||||
action: 'member.add',
|
||||
metadata: { userId: user.id, email, role },
|
||||
});
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'member.add',
|
||||
metadata: { userId: user.id, email, role },
|
||||
});
|
||||
|
||||
return reply.code(201).send(member);
|
||||
});
|
||||
return reply.code(201).send(member);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /api/organizations/:orgId/members/:memberId
|
||||
app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => {
|
||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
app.patch(
|
||||
'/:orgId/members/:memberId',
|
||||
{ schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } },
|
||||
async (request) => {
|
||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
|
||||
const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record<string, boolean> };
|
||||
const body = request.body as {
|
||||
role?: 'admin' | 'user';
|
||||
customPermissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(organizationMembers)
|
||||
.set(body)
|
||||
.where(and(
|
||||
eq(organizationMembers.id, memberId),
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
))
|
||||
.returning();
|
||||
const [updated] = await app.db
|
||||
.update(organizationMembers)
|
||||
.set(body)
|
||||
.where(
|
||||
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Member not found');
|
||||
if (!updated) throw AppError.notFound('Member not found');
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'member.update',
|
||||
metadata: { memberId, ...body },
|
||||
});
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'member.update',
|
||||
metadata: { memberId, ...body },
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/organizations/:orgId/members/:memberId
|
||||
app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => {
|
||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
app.delete(
|
||||
'/:orgId/members/:memberId',
|
||||
{ schema: MemberIdParamSchema },
|
||||
async (request, reply) => {
|
||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
||||
await requirePermission(request, orgId, 'org.members');
|
||||
|
||||
const member = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.id, memberId),
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
),
|
||||
});
|
||||
if (!member) throw AppError.notFound('Member not found');
|
||||
const member = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.id, memberId),
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
),
|
||||
});
|
||||
if (!member) throw AppError.notFound('Member not found');
|
||||
|
||||
// Cannot remove org owner
|
||||
const org = await app.db.query.organizations.findFirst({
|
||||
where: eq(organizations.id, orgId),
|
||||
});
|
||||
if (org && member.userId === org.ownerId) {
|
||||
throw AppError.badRequest('Cannot remove the organization owner');
|
||||
}
|
||||
// Cannot remove org owner
|
||||
const org = await app.db.query.organizations.findFirst({
|
||||
where: eq(organizations.id, orgId),
|
||||
});
|
||||
if (org && member.userId === org.ownerId) {
|
||||
throw AppError.badRequest('Cannot remove the organization owner');
|
||||
}
|
||||
|
||||
await app.db
|
||||
.delete(organizationMembers)
|
||||
.where(and(
|
||||
eq(organizationMembers.id, memberId),
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
));
|
||||
await app.db
|
||||
.delete(organizationMembers)
|
||||
.where(
|
||||
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
|
||||
);
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'member.remove',
|
||||
metadata: { memberId, userId: member.userId },
|
||||
});
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'member.remove',
|
||||
metadata: { memberId, userId: member.userId },
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
|
||||
completedBackup = updated ?? completedBackup;
|
||||
} catch (error) {
|
||||
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
|
||||
request.log.error(
|
||||
{ error, serverId, backupId: backup.id },
|
||||
'Failed to create backup on daemon',
|
||||
);
|
||||
await app.db.delete(backups).where(eq(backups.id, backup.id));
|
||||
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
|
||||
}
|
||||
@@ -140,10 +143,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
backup.cdnPath,
|
||||
);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to restore backup on daemon',
|
||||
);
|
||||
request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon');
|
||||
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
||||
}
|
||||
|
||||
@@ -200,10 +200,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to delete backup on daemon',
|
||||
);
|
||||
request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon');
|
||||
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,12 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
};
|
||||
await requirePermission(request, orgId, 'config.read');
|
||||
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { game, server, node, configFile } = await getServerConfig(
|
||||
app,
|
||||
orgId,
|
||||
serverId,
|
||||
configIndex,
|
||||
);
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
@@ -79,8 +84,15 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
||||
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
|
||||
app.log.error(
|
||||
{ error, serverId, path: configFile.path },
|
||||
'Failed to read config file from daemon',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
'Failed to read config file from daemon',
|
||||
'DAEMON_CONFIG_READ_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +131,12 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||
await requirePermission(request, orgId, 'config.write');
|
||||
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { game, server, node, configFile } = await getServerConfig(
|
||||
app,
|
||||
orgId,
|
||||
serverId,
|
||||
configIndex,
|
||||
);
|
||||
|
||||
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||
|
||||
@@ -135,8 +152,15 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
||||
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
|
||||
app.log.error(
|
||||
{ error, serverId, path: configFile.path },
|
||||
'Failed to read existing config before write',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
'Failed to read existing config file',
|
||||
'DAEMON_CONFIG_READ_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +180,7 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const content = serializeConfig(
|
||||
entries,
|
||||
configFile.parser as ConfigParser,
|
||||
originalContent,
|
||||
);
|
||||
const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent);
|
||||
|
||||
if (managedFile) {
|
||||
await writeManagedConfig(node, server.uuid, managedFile, content);
|
||||
|
||||
@@ -99,180 +99,188 @@ export default async function databaseRoutes(app: FastifyInstance) {
|
||||
return { data: databases };
|
||||
});
|
||||
|
||||
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
app.post(
|
||||
'/',
|
||||
{ schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } },
|
||||
async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name: string; password?: string };
|
||||
const name = body.name.trim();
|
||||
if (!name) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
const body = request.body as { name: string; password?: string };
|
||||
const name = body.name.trim();
|
||||
if (!name) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, orgId, serverId);
|
||||
const server = await getServerContext(app, orgId, serverId);
|
||||
|
||||
let managedDatabase;
|
||||
try {
|
||||
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
||||
name,
|
||||
password: body.password,
|
||||
serverUuid: server.uuid,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, serverUuid: server.uuid },
|
||||
'Failed to provision node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
|
||||
'MANAGED_MYSQL_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId,
|
||||
let managedDatabase;
|
||||
try {
|
||||
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
||||
name,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
password: body.password,
|
||||
serverUuid: server.uuid,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, serverUuid: server.uuid },
|
||||
'Failed to provision node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
|
||||
'MANAGED_MYSQL_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId,
|
||||
name,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.create',
|
||||
metadata: {
|
||||
name: created!.name,
|
||||
databaseName: created!.databaseName,
|
||||
username: created!.username,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(server), {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
request.log.error(
|
||||
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to roll back node-local MySQL database after panel insert failure',
|
||||
);
|
||||
}
|
||||
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to persist managed MySQL database metadata',
|
||||
);
|
||||
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/:databaseId',
|
||||
{ schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } },
|
||||
async (request) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name?: string; password?: string };
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
createdAt: serverDatabases.createdAt,
|
||||
updatedAt: serverDatabases.updatedAt,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
const nextName = body.name === undefined ? undefined : body.name.trim();
|
||||
if (body.name !== undefined && !nextName) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const nextPassword = body.password?.trim();
|
||||
if (!nextName && !nextPassword) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (nextPassword) {
|
||||
try {
|
||||
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
|
||||
password: nextPassword,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, username: current.username },
|
||||
'Failed to rotate node-local MySQL password',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to rotate database password'),
|
||||
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (nextName) patch.name = nextName;
|
||||
if (nextPassword) patch.password = nextPassword;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(serverDatabases)
|
||||
.set(patch)
|
||||
.where(eq(serverDatabases.id, databaseId))
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.create',
|
||||
action: 'server.database.update',
|
||||
metadata: {
|
||||
name: created!.name,
|
||||
databaseName: created!.databaseName,
|
||||
username: created!.username,
|
||||
databaseId,
|
||||
updatedName: nextName ?? undefined,
|
||||
passwordRotated: Boolean(nextPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(server), {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
request.log.error(
|
||||
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to roll back node-local MySQL database after panel insert failure',
|
||||
);
|
||||
}
|
||||
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to persist managed MySQL database metadata',
|
||||
);
|
||||
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name?: string; password?: string };
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
createdAt: serverDatabases.createdAt,
|
||||
updatedAt: serverDatabases.updatedAt,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
const nextName = body.name === undefined ? undefined : body.name.trim();
|
||||
if (body.name !== undefined && !nextName) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const nextPassword = body.password?.trim();
|
||||
if (!nextName && !nextPassword) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (nextPassword) {
|
||||
try {
|
||||
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
|
||||
password: nextPassword,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, username: current.username },
|
||||
'Failed to rotate node-local MySQL password',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to rotate database password'),
|
||||
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (nextName) patch.name = nextName;
|
||||
if (nextPassword) patch.password = nextPassword;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(serverDatabases)
|
||||
.set(patch)
|
||||
.where(eq(serverDatabases.id, databaseId))
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.update',
|
||||
metadata: {
|
||||
databaseId,
|
||||
updatedName: nextName ?? undefined,
|
||||
passwordRotated: Boolean(nextPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
|
||||
@@ -121,9 +121,7 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
|
||||
return {
|
||||
data:
|
||||
requestedEncoding === 'base64'
|
||||
? payload.toString('base64')
|
||||
: payload.toString('utf8'),
|
||||
requestedEncoding === 'base64' ? payload.toString('base64') : payload.toString('utf8'),
|
||||
encoding: requestedEncoding,
|
||||
mimeType,
|
||||
};
|
||||
@@ -196,9 +194,7 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
|
||||
return [
|
||||
path,
|
||||
path.trim().startsWith('/')
|
||||
? `/${managedFile.shadowPath}`
|
||||
: managedFile.shadowPath,
|
||||
path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -208,7 +204,11 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<{
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
node: DaemonNodeConnection;
|
||||
|
||||
@@ -33,7 +33,11 @@ export default async function playerRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
|
||||
@@ -19,11 +19,7 @@ import {
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
searchSpigetPlugins,
|
||||
getSpigetResource,
|
||||
getSpigetDownloadUrl,
|
||||
} from '../../lib/spiget.js';
|
||||
import { searchSpigetPlugins, getSpigetResource, getSpigetDownloadUrl } from '../../lib/spiget.js';
|
||||
import { resolveArtifactDownloadUrl } from '../../lib/cdn.js';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
@@ -255,10 +251,20 @@ function parseBooleanLike(input: unknown): boolean | null {
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
if (
|
||||
normalized === 'true' ||
|
||||
normalized === '1' ||
|
||||
normalized === 'yes' ||
|
||||
normalized === 'on'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
if (
|
||||
normalized === 'false' ||
|
||||
normalized === '0' ||
|
||||
normalized === 'no' ||
|
||||
normalized === 'off'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -338,10 +344,12 @@ function validateInstallOptions(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function chooseBestRelease<T extends {
|
||||
channel: string;
|
||||
isPublished: boolean;
|
||||
}>(releases: T[], autoChannel: ReleaseChannel): T | null {
|
||||
function chooseBestRelease<
|
||||
T extends {
|
||||
channel: string;
|
||||
isPublished: boolean;
|
||||
},
|
||||
>(releases: T[], autoChannel: ReleaseChannel): T | null {
|
||||
for (const release of releases) {
|
||||
if (!release.isPublished) continue;
|
||||
const releaseChannel = resolveChannel(release.channel);
|
||||
@@ -394,11 +402,7 @@ async function getServerPluginContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function getPluginForGame(
|
||||
app: FastifyInstance,
|
||||
pluginId: string,
|
||||
gameId: string,
|
||||
) {
|
||||
async function getPluginForGame(app: FastifyInstance, pluginId: string, gameId: string) {
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
|
||||
});
|
||||
@@ -422,10 +426,7 @@ async function getPluginReleaseForPlugin(
|
||||
return release;
|
||||
}
|
||||
|
||||
async function listPublishedPluginReleases(
|
||||
app: FastifyInstance,
|
||||
pluginId: string,
|
||||
) {
|
||||
async function listPublishedPluginReleases(app: FastifyInstance, pluginId: string) {
|
||||
return app.db
|
||||
.select()
|
||||
.from(pluginReleases)
|
||||
@@ -484,17 +485,16 @@ async function downloadPluginArtifact(downloadUrl: string): Promise<Buffer> {
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
502,
|
||||
'Unable to download plugin artifact',
|
||||
'PLUGIN_DOWNLOAD_FAILED',
|
||||
);
|
||||
throw new AppError(502, 'Unable to download plugin artifact', 'PLUGIN_DOWNLOAD_FAILED');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZipArtifact(buffer: Buffer, destination: string): Promise<Array<{ path: string; data: Buffer }>> {
|
||||
async function extractZipArtifact(
|
||||
buffer: Buffer,
|
||||
destination: string,
|
||||
): Promise<Array<{ path: string; data: Buffer }>> {
|
||||
const archive = await unzipper.Open.buffer(buffer);
|
||||
const files: Array<{ path: string; data: Buffer }> = [];
|
||||
|
||||
@@ -545,15 +545,13 @@ async function insertServerPluginFileRows(
|
||||
): Promise<void> {
|
||||
if (paths.length === 0) return;
|
||||
|
||||
await app.db
|
||||
.insert(serverPluginFiles)
|
||||
.values(
|
||||
uniqPaths(paths).map((path) => ({
|
||||
serverPluginId,
|
||||
path,
|
||||
kind,
|
||||
})),
|
||||
);
|
||||
await app.db.insert(serverPluginFiles).values(
|
||||
uniqPaths(paths).map((path) => ({
|
||||
serverPluginId,
|
||||
path,
|
||||
kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function installReleaseArtifacts(
|
||||
@@ -643,9 +641,7 @@ async function removeInstalledPluginFiles(
|
||||
.from(serverPluginFiles)
|
||||
.where(eq(serverPluginFiles.serverPluginId, installId));
|
||||
|
||||
const candidates = tracked.length > 0
|
||||
? tracked.map((row) => row.path)
|
||||
: fallbackPaths;
|
||||
const candidates = tracked.length > 0 ? tracked.map((row) => row.path) : fallbackPaths;
|
||||
|
||||
const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path));
|
||||
|
||||
@@ -673,17 +669,12 @@ async function syncInstalledPluginConfigFiles(
|
||||
.select({ path: serverPluginFiles.path })
|
||||
.from(serverPluginFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(serverPluginFiles.serverPluginId, installId),
|
||||
eq(serverPluginFiles.kind, 'config'),
|
||||
),
|
||||
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
|
||||
);
|
||||
|
||||
const nextPaths = uniqPaths(configPaths);
|
||||
const nextPathSet = new Set(nextPaths);
|
||||
const stalePaths = tracked
|
||||
.map((row) => row.path)
|
||||
.filter((path) => !nextPathSet.has(path));
|
||||
const stalePaths = tracked.map((row) => row.path).filter((path) => !nextPathSet.has(path));
|
||||
|
||||
if (stalePaths.length > 0) {
|
||||
try {
|
||||
@@ -704,10 +695,7 @@ async function syncInstalledPluginConfigFiles(
|
||||
await app.db
|
||||
.delete(serverPluginFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(serverPluginFiles.serverPluginId, installId),
|
||||
eq(serverPluginFiles.kind, 'config'),
|
||||
),
|
||||
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
|
||||
);
|
||||
|
||||
await insertServerPluginFileRows(app, installId, nextPaths, 'config');
|
||||
@@ -830,10 +818,7 @@ async function installPluginForServer(
|
||||
}
|
||||
|
||||
const existing = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('Plugin is already installed');
|
||||
@@ -902,21 +887,22 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
.where(eq(serverPlugins.serverId, serverId));
|
||||
|
||||
const pluginIds = uniqPaths(installed.map((row) => row.pluginId));
|
||||
const releases = pluginIds.length > 0
|
||||
? await app.db
|
||||
.select({
|
||||
id: pluginReleases.id,
|
||||
pluginId: pluginReleases.pluginId,
|
||||
version: pluginReleases.version,
|
||||
channel: pluginReleases.channel,
|
||||
installSchema: pluginReleases.installSchema,
|
||||
isPublished: pluginReleases.isPublished,
|
||||
createdAt: pluginReleases.createdAt,
|
||||
})
|
||||
.from(pluginReleases)
|
||||
.where(inArray(pluginReleases.pluginId, pluginIds))
|
||||
.orderBy(desc(pluginReleases.createdAt))
|
||||
: [];
|
||||
const releases =
|
||||
pluginIds.length > 0
|
||||
? await app.db
|
||||
.select({
|
||||
id: pluginReleases.id,
|
||||
pluginId: pluginReleases.pluginId,
|
||||
version: pluginReleases.version,
|
||||
channel: pluginReleases.channel,
|
||||
installSchema: pluginReleases.installSchema,
|
||||
isPublished: pluginReleases.isPublished,
|
||||
createdAt: pluginReleases.createdAt,
|
||||
})
|
||||
.from(pluginReleases)
|
||||
.where(inArray(pluginReleases.pluginId, pluginIds))
|
||||
.orderBy(desc(pluginReleases.createdAt))
|
||||
: [];
|
||||
|
||||
const releasesByPlugin = new Map<string, typeof releases>();
|
||||
for (const release of releases) {
|
||||
@@ -929,15 +915,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
plugins: installed.map((row) => {
|
||||
const releaseList = releasesByPlugin.get(row.pluginId) ?? [];
|
||||
const currentRelease = row.releaseId
|
||||
? releaseList.find((release) => release.id === row.releaseId) ?? null
|
||||
? (releaseList.find((release) => release.id === row.releaseId) ?? null)
|
||||
: null;
|
||||
const currentChannel = resolveChannel(row.autoUpdateChannel);
|
||||
const latestAllowed = chooseBestRelease(releaseList, currentChannel);
|
||||
const updateAvailable = Boolean(
|
||||
!row.isPinned &&
|
||||
latestAllowed &&
|
||||
row.releaseId &&
|
||||
latestAllowed.id !== row.releaseId,
|
||||
!row.isPinned && latestAllowed && row.releaseId && latestAllowed.id !== row.releaseId,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1007,25 +990,31 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
.where(eq(serverPlugins.serverId, context.serverId));
|
||||
|
||||
const pluginIds = catalog.map((plugin) => plugin.id);
|
||||
const releaseRows = pluginIds.length > 0
|
||||
? await app.db
|
||||
.select({
|
||||
id: pluginReleases.id,
|
||||
pluginId: pluginReleases.pluginId,
|
||||
version: pluginReleases.version,
|
||||
channel: pluginReleases.channel,
|
||||
artifactType: pluginReleases.artifactType,
|
||||
artifactUrl: pluginReleases.artifactUrl,
|
||||
destination: pluginReleases.destination,
|
||||
fileName: pluginReleases.fileName,
|
||||
installSchema: pluginReleases.installSchema,
|
||||
isPublished: pluginReleases.isPublished,
|
||||
createdAt: pluginReleases.createdAt,
|
||||
})
|
||||
.from(pluginReleases)
|
||||
.where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true)))
|
||||
.orderBy(desc(pluginReleases.createdAt))
|
||||
: [];
|
||||
const releaseRows =
|
||||
pluginIds.length > 0
|
||||
? await app.db
|
||||
.select({
|
||||
id: pluginReleases.id,
|
||||
pluginId: pluginReleases.pluginId,
|
||||
version: pluginReleases.version,
|
||||
channel: pluginReleases.channel,
|
||||
artifactType: pluginReleases.artifactType,
|
||||
artifactUrl: pluginReleases.artifactUrl,
|
||||
destination: pluginReleases.destination,
|
||||
fileName: pluginReleases.fileName,
|
||||
installSchema: pluginReleases.installSchema,
|
||||
isPublished: pluginReleases.isPublished,
|
||||
createdAt: pluginReleases.createdAt,
|
||||
})
|
||||
.from(pluginReleases)
|
||||
.where(
|
||||
and(
|
||||
inArray(pluginReleases.pluginId, pluginIds),
|
||||
eq(pluginReleases.isPublished, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(pluginReleases.createdAt))
|
||||
: [];
|
||||
|
||||
const releaseByPlugin = new Map<string, typeof releaseRows>();
|
||||
for (const row of releaseRows) {
|
||||
@@ -1034,9 +1023,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
releaseByPlugin.set(row.pluginId, list);
|
||||
}
|
||||
|
||||
const installedByPluginId = new Map(
|
||||
installedRows.map((row) => [row.pluginId, row]),
|
||||
);
|
||||
const installedByPluginId = new Map(installedRows.map((row) => [row.pluginId, row]));
|
||||
|
||||
const needle = q?.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
@@ -1133,10 +1120,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: and(
|
||||
eq(plugins.gameId, context.gameId),
|
||||
eq(plugins.slug, normalizedSlug),
|
||||
),
|
||||
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, normalizedSlug)),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||
@@ -1203,18 +1187,18 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
const existing = await getPluginForGame(app, pluginId, context.gameId);
|
||||
|
||||
const nextSlug = body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
||||
const nextSlug =
|
||||
body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: body.name !== undefined
|
||||
? toSlug(body.name)
|
||||
: existing.slug;
|
||||
if (!nextSlug) {
|
||||
throw AppError.badRequest('Plugin slug is invalid');
|
||||
}
|
||||
|
||||
const duplicate = await app.db.query.plugins.findFirst({
|
||||
where: and(
|
||||
eq(plugins.gameId, context.gameId),
|
||||
eq(plugins.slug, nextSlug),
|
||||
),
|
||||
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, nextSlug)),
|
||||
});
|
||||
if (duplicate && duplicate.id !== existing.id) {
|
||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||
@@ -1338,16 +1322,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
body: Type.Optional(Type.Object({
|
||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||
pinVersion: Type.Optional(Type.Boolean()),
|
||||
autoUpdateChannel: Type.Optional(Type.Union([
|
||||
Type.Literal('stable'),
|
||||
Type.Literal('beta'),
|
||||
Type.Literal('alpha'),
|
||||
])),
|
||||
})),
|
||||
body: Type.Optional(
|
||||
Type.Object({
|
||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||
pinVersion: Type.Optional(Type.Boolean()),
|
||||
autoUpdateChannel: Type.Optional(
|
||||
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
@@ -1368,7 +1352,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||
|
||||
const existing = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('Plugin is already installed');
|
||||
@@ -1471,7 +1458,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { resourceId } = request.body as { resourceId: number; options?: Record<string, unknown> };
|
||||
const { resourceId } = request.body as {
|
||||
resourceId: number;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
@@ -1508,7 +1498,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
plugin = created!;
|
||||
}
|
||||
|
||||
const releaseVersion = resource.version ? String(resource.version.id) : `spiget-${Date.now()}`;
|
||||
const releaseVersion = resource.version
|
||||
? String(resource.version.id)
|
||||
: `spiget-${Date.now()}`;
|
||||
let release = await app.db.query.pluginReleases.findFirst({
|
||||
where: and(
|
||||
eq(pluginReleases.pluginId, plugin.id),
|
||||
@@ -1533,19 +1525,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const existing = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('Plugin is already installed');
|
||||
}
|
||||
|
||||
const installResult = await installPluginReleaseForServer(
|
||||
app,
|
||||
context,
|
||||
plugin,
|
||||
release,
|
||||
{},
|
||||
);
|
||||
const installResult = await installPluginReleaseForServer(app, context, plugin, release, {});
|
||||
|
||||
const [installed] = await app.db
|
||||
.insert(serverPlugins)
|
||||
@@ -1614,9 +1603,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
? normalizeAbsolutePath(filePath)
|
||||
: joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath);
|
||||
|
||||
let plugin = pluginId
|
||||
? await getPluginForGame(app, pluginId, context.gameId)
|
||||
: null;
|
||||
let plugin = pluginId ? await getPluginForGame(app, pluginId, context.gameId) : null;
|
||||
|
||||
if (!plugin) {
|
||||
const slug = toSlug(name);
|
||||
@@ -1642,7 +1629,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const existingInstall = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
});
|
||||
if (existingInstall) {
|
||||
throw AppError.conflict('Plugin is already installed');
|
||||
@@ -1669,7 +1659,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.install',
|
||||
metadata: { pluginId: plugin.id, name: plugin.name, source: 'manual', filePath: normalizedPath },
|
||||
metadata: {
|
||||
pluginId: plugin.id,
|
||||
name: plugin.name,
|
||||
source: 'manual',
|
||||
filePath: normalizedPath,
|
||||
},
|
||||
});
|
||||
|
||||
return installed;
|
||||
@@ -1710,24 +1705,27 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
.from(serverPlugins)
|
||||
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
|
||||
.leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id))
|
||||
.where(and(
|
||||
eq(serverPlugins.id, pluginInstallId),
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
));
|
||||
.where(
|
||||
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
throw AppError.notFound('Plugin installation not found');
|
||||
}
|
||||
|
||||
const fallbackPath = installed.releaseArtifactUrl
|
||||
? resolveReleaseFilePath(context.gameSlug, {
|
||||
id: installed.pluginId,
|
||||
slug: installed.pluginSlug,
|
||||
}, {
|
||||
artifactUrl: installed.releaseArtifactUrl,
|
||||
destination: installed.releaseDestination,
|
||||
fileName: installed.releaseFileName,
|
||||
})
|
||||
? resolveReleaseFilePath(
|
||||
context.gameSlug,
|
||||
{
|
||||
id: installed.pluginId,
|
||||
slug: installed.pluginSlug,
|
||||
},
|
||||
{
|
||||
artifactUrl: installed.releaseArtifactUrl,
|
||||
destination: installed.releaseDestination,
|
||||
fileName: installed.releaseFileName,
|
||||
},
|
||||
)
|
||||
: pluginFilePath(context.gameSlug, {
|
||||
id: installed.pluginId,
|
||||
slug: installed.pluginSlug,
|
||||
@@ -1809,16 +1807,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
pluginInstallId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
body: Type.Optional(Type.Object({
|
||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||
pinVersion: Type.Optional(Type.Boolean()),
|
||||
autoUpdateChannel: Type.Optional(Type.Union([
|
||||
Type.Literal('stable'),
|
||||
Type.Literal('beta'),
|
||||
Type.Literal('alpha'),
|
||||
])),
|
||||
})),
|
||||
body: Type.Optional(
|
||||
Type.Object({
|
||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||
pinVersion: Type.Optional(Type.Boolean()),
|
||||
autoUpdateChannel: Type.Optional(
|
||||
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
@@ -1847,7 +1845,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
autoUpdateChannel: serverPlugins.autoUpdateChannel,
|
||||
})
|
||||
.from(serverPlugins)
|
||||
.where(and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)));
|
||||
.where(
|
||||
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
throw AppError.notFound('Plugin installation not found');
|
||||
@@ -1880,8 +1880,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const nextPinned = body.pinVersion ?? installed.isPinned;
|
||||
const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel;
|
||||
const hasMetadataChanges =
|
||||
nextPinned !== installed.isPinned ||
|
||||
nextAutoUpdateChannel !== installed.autoUpdateChannel;
|
||||
nextPinned !== installed.isPinned || nextAutoUpdateChannel !== installed.autoUpdateChannel;
|
||||
|
||||
if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) {
|
||||
throw AppError.conflict('Plugin is already on the selected release');
|
||||
@@ -1936,11 +1935,24 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
mergedOptions,
|
||||
);
|
||||
|
||||
const newPaths = uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]);
|
||||
const newPaths = uniqPaths([
|
||||
...installResult.artifactPaths,
|
||||
...installResult.configPaths,
|
||||
]);
|
||||
await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths);
|
||||
|
||||
await insertServerPluginFileRows(app, installed.installId, installResult.artifactPaths, 'artifact');
|
||||
await insertServerPluginFileRows(app, installed.installId, installResult.configPaths, 'config');
|
||||
await insertServerPluginFileRows(
|
||||
app,
|
||||
installed.installId,
|
||||
installResult.artifactPaths,
|
||||
'artifact',
|
||||
);
|
||||
await insertServerPluginFileRows(
|
||||
app,
|
||||
installed.installId,
|
||||
installResult.configPaths,
|
||||
'config',
|
||||
);
|
||||
nextInstallOptions = installResult.installOptions;
|
||||
} else {
|
||||
const configureResult = await configurePluginReleaseForServer(
|
||||
|
||||
@@ -30,11 +30,7 @@ const TaskParamSchema = {
|
||||
|
||||
const CreateScheduleBody = Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
action: Type.Union([
|
||||
Type.Literal('command'),
|
||||
Type.Literal('power'),
|
||||
Type.Literal('backup'),
|
||||
]),
|
||||
action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
|
||||
payload: Type.String({ minLength: 1 }),
|
||||
scheduleType: Type.Union([
|
||||
Type.Literal('interval'),
|
||||
@@ -131,34 +127,40 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// PATCH /schedules/:taskId — update a scheduled task
|
||||
app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => {
|
||||
const { orgId, serverId, taskId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
taskId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
app.patch(
|
||||
'/:taskId',
|
||||
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
|
||||
async (request) => {
|
||||
const { orgId, serverId, taskId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
taskId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const existing = await app.db.query.scheduledTasks.findFirst({
|
||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Scheduled task not found');
|
||||
const existing = await app.db.query.scheduledTasks.findFirst({
|
||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
// Recompute next run if schedule changed
|
||||
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
|
||||
const scheduleData = (body.scheduleData as Record<string, unknown>) || (existing.scheduleData as Record<string, unknown>);
|
||||
const nextRun = computeNextRun(scheduleType, scheduleData);
|
||||
// Recompute next run if schedule changed
|
||||
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
|
||||
const scheduleData =
|
||||
(body.scheduleData as Record<string, unknown>) ||
|
||||
(existing.scheduleData as Record<string, unknown>);
|
||||
const nextRun = computeNextRun(scheduleType, scheduleData);
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
|
||||
.where(eq(scheduledTasks.id, taskId))
|
||||
.returning();
|
||||
const [updated] = await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
|
||||
.where(eq(scheduledTasks.id, taskId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /schedules/:taskId — delete a scheduled task
|
||||
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
|
||||
@@ -223,7 +225,11 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.83-bookworm AS build
|
||||
FROM rust:1.97-bookworm AS build
|
||||
|
||||
# Install protoc
|
||||
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+8
-2
@@ -8,7 +8,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
# pnpm creates no node_modules for a workspace package that has no
|
||||
# dependencies of its own, and @source/shared has none. The COPY lines below
|
||||
# name that path, so give them an empty directory to find instead of failing
|
||||
# the build on a path pnpm never made.
|
||||
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS build
|
||||
@@ -32,6 +36,8 @@ COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1
|
||||
# 127.0.0.1 for the same reason as the API image: localhost resolves to
|
||||
# ::1, where nginx is not listening.
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+49
-49
@@ -76,62 +76,62 @@ function AuthGuard() {
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route element={<AppLayout />}>
|
||||
{/* Organizations */}
|
||||
<Route path="/" element={<OrganizationsPage />} />
|
||||
{/* Protected routes */}
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route element={<AppLayout />}>
|
||||
{/* Organizations */}
|
||||
<Route path="/" element={<OrganizationsPage />} />
|
||||
|
||||
{/* Org-scoped routes */}
|
||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||
{/* Org-scoped routes */}
|
||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||
|
||||
{/* Account */}
|
||||
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||
{/* Account */}
|
||||
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||
|
||||
{/* Server detail */}
|
||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||
<Route index element={<Navigate to="console" replace />} />
|
||||
<Route path="console" element={<ConsolePage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="databases" element={<DatabasesPage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="schedules" element={<SchedulesPage />} />
|
||||
<Route path="players" element={<PlayersPage />} />
|
||||
<Route path="settings" element={<ServerSettingsPage />} />
|
||||
{/* Server detail */}
|
||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||
<Route index element={<Navigate to="console" replace />} />
|
||||
<Route path="console" element={<ConsolePage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="databases" element={<DatabasesPage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="schedules" element={<SchedulesPage />} />
|
||||
<Route path="players" element={<PlayersPage />} />
|
||||
<Route path="settings" element={<ServerSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react';
|
||||
import {
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Calendar,
|
||||
HardDrive,
|
||||
Users,
|
||||
Puzzle,
|
||||
Settings2,
|
||||
Database as DatabaseIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -52,9 +62,7 @@ export function ServerLayout() {
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{server?.name ?? 'Loading...'}</h1>
|
||||
{server && (
|
||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||
)}
|
||||
{server && <Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>}
|
||||
</div>
|
||||
{server && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -96,8 +96,7 @@ function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: str
|
||||
return (
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
currentPath === item.href || currentPath.startsWith(item.href + '/');
|
||||
const isActive = currentPath === item.href || currentPath.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link key={item.href} to={item.href}>
|
||||
<Button
|
||||
|
||||
@@ -93,11 +93,7 @@ export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isTransitioning && !isRunning}
|
||||
>
|
||||
<Button size="sm" variant="destructive" disabled={isTransitioning && !isRunning}>
|
||||
<Skull className="h-4 w-4" />
|
||||
Kill
|
||||
</Button>
|
||||
|
||||
@@ -18,8 +18,7 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
|
||||
@@ -10,7 +10,8 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
@@ -30,15 +31,16 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
@@ -3,7 +3,11 @@ import { cn } from '@source/ui';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
@@ -17,7 +21,11 @@ CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
@@ -30,7 +38,9 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
||||
);
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
@@ -72,7 +75,11 @@ const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@@ -111,8 +111,7 @@ async function refreshToken(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, string>) =>
|
||||
request<T>(path, { params }),
|
||||
get: <T>(path: string, params?: Record<string, string>) => request<T>(path, { params }),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
@@ -132,8 +131,7 @@ export const api = {
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -186,9 +186,7 @@ export function AdminGamesPage() {
|
||||
<Label>Slug</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) =>
|
||||
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
|
||||
}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -241,7 +239,10 @@ export function AdminGamesPage() {
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||
<p>Port: {game.defaultPort}</p>
|
||||
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
||||
<p>
|
||||
Automation:{' '}
|
||||
{Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -277,7 +278,8 @@ export function AdminGamesPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>JSON</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
||||
Supported events: server.created, server.install.completed, server.power.started,
|
||||
server.power.stopped
|
||||
</p>
|
||||
<textarea
|
||||
value={automationJson}
|
||||
|
||||
@@ -42,14 +42,20 @@ export function AdminNodesPage() {
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{node.fqdn}:{node.daemonPort}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
|
||||
@@ -197,12 +197,14 @@ export function AdminPluginsPage() {
|
||||
const map = new Map<string, File>();
|
||||
|
||||
for (const item of prev) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
const relative =
|
||||
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
for (const item of Array.from(incoming)) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
const relative =
|
||||
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
@@ -211,12 +213,8 @@ export function AdminPluginsPage() {
|
||||
};
|
||||
|
||||
const createPluginMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
gameId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}) => api.post('/admin/plugins', body),
|
||||
mutationFn: (body: { gameId: string; name: string; slug?: string; description?: string }) =>
|
||||
api.post('/admin/plugins', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Global plugin created');
|
||||
setCreatePluginOpen(false);
|
||||
@@ -366,15 +364,25 @@ export function AdminPluginsPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
||||
<Input
|
||||
value={createPluginName}
|
||||
onChange={(e) => setCreatePluginName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
||||
<Input
|
||||
value={createPluginSlug}
|
||||
onChange={(e) => setCreatePluginSlug(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
||||
<Input
|
||||
value={createPluginDescription}
|
||||
onChange={(e) => setCreatePluginDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||
@@ -424,7 +432,9 @@ export function AdminPluginsPage() {
|
||||
type="button"
|
||||
onClick={() => setSelectedPluginId(plugin.id)}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
||||
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||
selectedPluginId === plugin.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -454,11 +464,7 @@ export function AdminPluginsPage() {
|
||||
>
|
||||
<Copy className="h-4 w-4" /> Clone Latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openReleaseDialogFrom()}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<Button size="sm" onClick={() => openReleaseDialogFrom()} disabled={!selectedPlugin}>
|
||||
<UploadCloud className="h-4 w-4" /> New Release
|
||||
</Button>
|
||||
</div>
|
||||
@@ -478,9 +484,12 @@ export function AdminPluginsPage() {
|
||||
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||
{release.artifactUrl}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0}{' '}
|
||||
fields • Templates:{' '}
|
||||
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
@@ -517,7 +526,9 @@ export function AdminPluginsPage() {
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
||||
<DialogTitle>
|
||||
Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -540,7 +551,8 @@ export function AdminPluginsPage() {
|
||||
const formData = new FormData();
|
||||
formData.append('version', releaseVersion);
|
||||
formData.append('channel', releaseChannel);
|
||||
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
||||
if (releaseDestination.trim())
|
||||
formData.append('destination', releaseDestination.trim());
|
||||
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||
if (releaseInstallSchemaFile) {
|
||||
@@ -563,8 +575,12 @@ export function AdminPluginsPage() {
|
||||
}
|
||||
|
||||
for (const file of releaseArtifactFiles) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })
|
||||
.webkitRelativePath;
|
||||
formData.append(
|
||||
'relativePath',
|
||||
relativePath && relativePath.length > 0 ? relativePath : file.name,
|
||||
);
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
|
||||
@@ -592,7 +608,11 @@ export function AdminPluginsPage() {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Version</Label>
|
||||
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
||||
<Input
|
||||
value={releaseVersion}
|
||||
onChange={(e) => setReleaseVersion(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
@@ -658,8 +678,8 @@ export function AdminPluginsPage() {
|
||||
{releaseInputMode === 'upload' && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
||||
yapilip CDN'e yuklenir.
|
||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen
|
||||
otomatik zip yapilip CDN'e yuklenir.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -683,9 +703,16 @@ export function AdminPluginsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected: {releaseArtifactFiles.length} file(s)
|
||||
</p>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setReleaseArtifactFiles([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
@@ -693,7 +720,8 @@ export function AdminPluginsPage() {
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||
{releaseArtifactFiles.map((file, index) => {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })
|
||||
.webkitRelativePath;
|
||||
return (
|
||||
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||
{relativePath || file.name}
|
||||
@@ -745,7 +773,10 @@ export function AdminPluginsPage() {
|
||||
/>
|
||||
{releaseInstallSchemaFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<p>
|
||||
File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override
|
||||
eder.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -776,7 +807,10 @@ export function AdminPluginsPage() {
|
||||
/>
|
||||
{releaseTemplatesFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<p>
|
||||
File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override
|
||||
eder.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -807,7 +841,7 @@ export function AdminPluginsPage() {
|
||||
!selectedPlugin
|
||||
}
|
||||
>
|
||||
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
||||
{createReleaseMutation.isPending || createUploadReleaseMutation.isPending
|
||||
? 'Publishing...'
|
||||
: 'Publish Release'}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,14 @@ import { Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ApiError } from '@/lib/api';
|
||||
|
||||
@@ -47,7 +54,9 @@ export function LoginPage() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
|
||||
@@ -4,7 +4,14 @@ import { Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ApiError } from '@/lib/api';
|
||||
|
||||
@@ -48,7 +55,9 @@ export function RegisterPage() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
|
||||
@@ -54,7 +54,9 @@ export function DashboardPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Servers</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Servers
|
||||
</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -93,17 +93,13 @@ export function NodeDetailPage() {
|
||||
const { data: serversData } = useQuery({
|
||||
queryKey: ['node-servers', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: ServerSummary[] }>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/servers`,
|
||||
),
|
||||
api.get<{ data: ServerSummary[] }>(`/organizations/${orgId}/nodes/${nodeId}/servers`),
|
||||
});
|
||||
|
||||
const { data: allocData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: Allocation[] }>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
api.get<{ data: Allocation[] }>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
||||
});
|
||||
|
||||
const allocations = allocData?.data ?? [];
|
||||
@@ -142,12 +138,10 @@ export function NodeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const memPercent = stats && stats.memoryTotal > 0
|
||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
||||
: 0;
|
||||
const diskPercent = stats && stats.diskTotal > 0
|
||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
||||
: 0;
|
||||
const memPercent =
|
||||
stats && stats.memoryTotal > 0 ? Math.round((stats.memoryUsed / stats.memoryTotal) * 100) : 0;
|
||||
const diskPercent =
|
||||
stats && stats.diskTotal > 0 ? Math.round((stats.diskUsed / stats.diskTotal) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -167,9 +161,13 @@ export function NodeDetailPage() {
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -197,9 +195,7 @@ export function NodeDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats
|
||||
? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}`
|
||||
: '—'}
|
||||
{stats ? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}` : '—'}
|
||||
</div>
|
||||
<Progress value={memPercent} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
@@ -212,9 +208,7 @@ export function NodeDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats
|
||||
? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}`
|
||||
: '—'}
|
||||
{stats ? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}` : '—'}
|
||||
</div>
|
||||
<Progress value={diskPercent} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
@@ -248,9 +242,7 @@ export function NodeDetailPage() {
|
||||
<InfoRow label="gRPC Port" value={String(node.grpcPort)} />
|
||||
<InfoRow label="Total Memory" value={formatBytes(node.memoryTotal)} />
|
||||
<InfoRow label="Total Disk" value={formatBytes(node.diskTotal)} />
|
||||
{node.daemonVersion && (
|
||||
<InfoRow label="Daemon Version" value={node.daemonVersion} />
|
||||
)}
|
||||
{node.daemonVersion && <InfoRow label="Daemon Version" value={node.daemonVersion} />}
|
||||
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -277,9 +269,7 @@ export function NodeDetailPage() {
|
||||
<p className="text-xs text-muted-foreground">{srv.gameName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={srv.status === 'running' ? 'default' : 'outline'}
|
||||
>
|
||||
<Badge variant={srv.status === 'running' ? 'default' : 'outline'}>
|
||||
{srv.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -373,7 +363,10 @@ export function NodeDetailPage() {
|
||||
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
||||
function parsePorts(input: string): number[] {
|
||||
const ports: number[] = [];
|
||||
const parts = input.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const parts = input
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
|
||||
@@ -183,16 +183,8 @@ export function NodesPage() {
|
||||
<div className="space-y-3">
|
||||
<Label>Daemon Token</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={createdToken}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
<Input readOnly value={createdToken} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" onClick={handleCopyToken}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
@@ -201,7 +193,8 @@ export function NodesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use this token in your daemon configuration file (config.yml) to authenticate with the panel.
|
||||
Use this token in your daemon configuration file (config.yml) to authenticate with the
|
||||
panel.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -213,28 +206,34 @@ export function NodesPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{node.fqdn}:{node.daemonPort}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -54,16 +54,13 @@ export function BackupsPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['backups', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<{ backups: Backup[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/backups`,
|
||||
),
|
||||
api.get<{ backups: Backup[] }>(`/organizations/${orgId}/servers/${serverId}/backups`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
@@ -75,8 +72,7 @@ export function BackupsPage() {
|
||||
const lockMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||
});
|
||||
|
||||
const backupList = data?.backups ?? [];
|
||||
@@ -89,7 +85,8 @@ export function BackupsPage() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Backups</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{backupList.length} backup{backupList.length !== 1 ? 's' : ''} — {formatBytes(totalSize)} total
|
||||
{backupList.length} backup{backupList.length !== 1 ? 's' : ''} —{' '}
|
||||
{formatBytes(totalSize)} total
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
@@ -154,9 +151,7 @@ export function BackupsPage() {
|
||||
<span>{formatBytes(backup.sizeBytes)}</span>
|
||||
<span>{new Date(backup.createdAt).toLocaleString()}</span>
|
||||
{backup.checksum && (
|
||||
<span className="font-mono">
|
||||
{backup.checksum.slice(0, 12)}...
|
||||
</span>
|
||||
<span className="font-mono">{backup.checksum.slice(0, 12)}...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,9 +233,7 @@ function CreateBackupForm({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(
|
||||
`backup-${new Date().toISOString().slice(0, 10)}`,
|
||||
);
|
||||
const [name, setName] = useState(`backup-${new Date().toISOString().slice(0, 10)}`);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string }) =>
|
||||
@@ -261,11 +254,7 @@ function CreateBackupForm({
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Backup Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -30,10 +30,7 @@ interface ConfigDetail {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function mergeConfigEntries(
|
||||
entries: ConfigEntry[],
|
||||
editableKeys: string[] | null,
|
||||
): ConfigEntry[] {
|
||||
function mergeConfigEntries(entries: ConfigEntry[], editableKeys: string[] | null): ConfigEntry[] {
|
||||
if (!editableKeys || editableKeys.length === 0) return entries;
|
||||
|
||||
const existing = new Map(entries.map((entry) => [entry.key, entry]));
|
||||
@@ -55,9 +52,7 @@ export function ConfigPage() {
|
||||
const { data: configsData } = useQuery({
|
||||
queryKey: ['configs', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<{ configs: ConfigFile[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/config`,
|
||||
),
|
||||
api.get<{ configs: ConfigFile[] }>(`/organizations/${orgId}/servers/${serverId}/config`),
|
||||
});
|
||||
|
||||
const configs = configsData?.configs ?? [];
|
||||
@@ -114,9 +109,7 @@ function ConfigEditor({
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ['config-detail', orgId, serverId, configIndex],
|
||||
queryFn: () =>
|
||||
api.get<ConfigDetail>(
|
||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
||||
),
|
||||
api.get<ConfigDetail>(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`),
|
||||
});
|
||||
|
||||
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
||||
@@ -128,10 +121,7 @@ function ConfigEditor({
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||
api.put(
|
||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
||||
data,
|
||||
),
|
||||
api.put(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-detail', orgId, serverId, configIndex],
|
||||
@@ -140,9 +130,7 @@ function ConfigEditor({
|
||||
});
|
||||
|
||||
const updateEntry = (key: string, value: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.key === key ? { ...e, value } : e)),
|
||||
);
|
||||
setEntries((prev) => prev.map((e) => (e.key === key ? { ...e, value } : e)));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -171,15 +159,15 @@ function ConfigEditor({
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
|
||||
{detail
|
||||
? 'No entries found. The server may need to be started first to generate config files.'
|
||||
: 'Loading...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="grid gap-1.5">
|
||||
<Label className="font-mono text-xs text-muted-foreground">
|
||||
{entry.key}
|
||||
</Label>
|
||||
<Label className="font-mono text-xs text-muted-foreground">{entry.key}</Label>
|
||||
<Input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(entry.key, e.target.value)}
|
||||
|
||||
@@ -6,7 +6,14 @@ import { toast } from 'sonner';
|
||||
import { ApiError, api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
@@ -38,9 +45,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">
|
||||
{value}
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,9 +64,7 @@ export function DatabasesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['server-databases', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: ManagedDatabase[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/databases`,
|
||||
),
|
||||
api.get<{ data: ManagedDatabase[] }>(`/organizations/${orgId}/servers/${serverId}/databases`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,11 +273,7 @@ export function DatabasesPage() {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingDatabase(database)}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingDatabase(database)}>
|
||||
<RefreshCw className="h-4 w-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
@@ -307,7 +306,8 @@ export function DatabasesPage() {
|
||||
/>
|
||||
{!database.phpMyAdminUrl ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
|
||||
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the
|
||||
daemon config for this node.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -68,10 +68,7 @@ function joinRemotePath(basePath: string, relativePath: string): string {
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
|
||||
const baseSegments = basePath
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
const baseSegments = basePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
|
||||
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
@@ -133,18 +130,16 @@ export function FilesPage() {
|
||||
null,
|
||||
);
|
||||
|
||||
const hasUnsavedChanges =
|
||||
!!editingFile && editingFile.content !== editingFile.originalContent;
|
||||
const hasUnsavedChanges = !!editingFile && editingFile.content !== editingFile.originalContent;
|
||||
const isUploading = !!uploadProgress;
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ['files', orgId, serverId, currentPath],
|
||||
enabled: Boolean(orgId && serverId) && !editingFile,
|
||||
queryFn: () =>
|
||||
api.get<{ files: FileEntry[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/files`,
|
||||
{ path: currentPath },
|
||||
),
|
||||
api.get<{ files: FileEntry[] }>(`/organizations/${orgId}/servers/${serverId}/files`, {
|
||||
path: currentPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -528,9 +523,7 @@ export function FilesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-xs text-amber-600">Unsaved changes</span>
|
||||
)}
|
||||
{hasUnsavedChanges && <span className="text-xs text-amber-600">Unsaved changes</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveCurrentFile}
|
||||
@@ -550,9 +543,7 @@ export function FilesPage() {
|
||||
ref={editorRef}
|
||||
value={editingFile.content}
|
||||
onChange={(event) =>
|
||||
setEditingFile((prev) =>
|
||||
prev ? { ...prev, content: event.target.value } : prev,
|
||||
)
|
||||
setEditingFile((prev) => (prev ? { ...prev, content: event.target.value } : prev))
|
||||
}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
className="min-h-[560px] w-full resize-y rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -611,21 +602,11 @@ export function FilesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={triggerUploadFiles}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={triggerUploadFiles} disabled={isUploading}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Files
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={triggerUploadFolder}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={triggerUploadFolder} disabled={isUploading}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Folder
|
||||
</Button>
|
||||
@@ -746,11 +727,7 @@ export function FilesPage() {
|
||||
{filesQuery.isError && (
|
||||
<div className="space-y-2 py-8 text-center">
|
||||
<p className="text-sm text-destructive">Failed to load directory</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => filesQuery.refetch()}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={() => filesQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -826,8 +803,7 @@ export function FilesPage() {
|
||||
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<code className="font-mono">{deleteTarget?.path}</code>?
|
||||
Are you sure you want to delete <code className="font-mono">{deleteTarget?.path}</code>?
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -21,9 +21,7 @@ export function PlayersPage() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['players', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<PlayerListResponse>(
|
||||
`/organizations/${orgId}/servers/${serverId}/players`,
|
||||
),
|
||||
api.get<PlayerListResponse>(`/organizations/${orgId}/servers/${serverId}/players`),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
|
||||
@@ -293,7 +293,9 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
|
||||
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
|
||||
const [installPinVersion, setInstallPinVersion] = useState(false);
|
||||
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<
|
||||
'stable' | 'beta' | 'alpha'
|
||||
>('stable');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
||||
@@ -317,7 +319,10 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
|
||||
};
|
||||
}) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`, payload ?? {}),
|
||||
api.post(
|
||||
`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`,
|
||||
payload ?? {},
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('Plugin installed');
|
||||
setInstallDialogOpen(false);
|
||||
@@ -364,8 +369,7 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||
description?: string;
|
||||
downloadUrl: string;
|
||||
version?: string;
|
||||
}) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
||||
}) => api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('Marketplace plugin added');
|
||||
setCreateOpen(false);
|
||||
@@ -655,7 +659,9 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => updateInstallMutation.mutate({ installId: plugin.installId! })}
|
||||
onClick={() =>
|
||||
updateInstallMutation.mutate({ installId: plugin.installId! })
|
||||
}
|
||||
disabled={updateInstallMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
@@ -893,14 +899,18 @@ function InstalledPlugins({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{plugin.name}</p>
|
||||
<Badge variant="outline">{plugin.source}</Badge>
|
||||
{plugin.installedVersion && <Badge variant="secondary">v{plugin.installedVersion}</Badge>}
|
||||
{plugin.installedVersion && (
|
||||
<Badge variant="secondary">v{plugin.installedVersion}</Badge>
|
||||
)}
|
||||
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
|
||||
{plugin.status !== 'installed' && (
|
||||
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
|
||||
{plugin.status}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
|
||||
{plugin.updateAvailable && (
|
||||
<Badge variant="destructive">Update Available</Badge>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||
@@ -1157,7 +1167,8 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun varsayılan plugin dizinine göre çözülür.
|
||||
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun
|
||||
varsayılan plugin dizinine göre çözülür.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -63,30 +63,25 @@ export function SchedulesPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['schedules', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<{ tasks: ScheduledTask[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/schedules`,
|
||||
),
|
||||
api.get<{ tasks: ScheduledTask[] }>(`/organizations/${orgId}/servers/${serverId}/schedules`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (taskId: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: (taskId: string) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}/trigger`, {}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ taskId, isActive }: { taskId: string; isActive: boolean }) =>
|
||||
api.patch(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`, { isActive }),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||
});
|
||||
|
||||
const tasks = data?.tasks ?? [];
|
||||
@@ -150,14 +145,10 @@ export function SchedulesPage() {
|
||||
{formatSchedule(task.scheduleType, task.scheduleData)}
|
||||
</span>
|
||||
{task.nextRunAt && (
|
||||
<span>
|
||||
Next: {new Date(task.nextRunAt).toLocaleString()}
|
||||
</span>
|
||||
<span>Next: {new Date(task.nextRunAt).toLocaleString()}</span>
|
||||
)}
|
||||
{task.lastRunAt && (
|
||||
<span>
|
||||
Last: {new Date(task.lastRunAt).toLocaleString()}
|
||||
</span>
|
||||
<span>Last: {new Date(task.lastRunAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
{task.action === 'command' && (
|
||||
@@ -188,11 +179,7 @@ export function SchedulesPage() {
|
||||
}
|
||||
title={task.isActive ? 'Pause' : 'Resume'}
|
||||
>
|
||||
{task.isActive ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{task.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -243,7 +230,9 @@ function CreateScheduleForm({
|
||||
const [name, setName] = useState('');
|
||||
const [action, setAction] = useState<'command' | 'power' | 'backup'>('command');
|
||||
const [payload, setPayload] = useState('');
|
||||
const [scheduleType, setScheduleType] = useState<'interval' | 'daily' | 'weekly' | 'cron'>('interval');
|
||||
const [scheduleType, setScheduleType] = useState<'interval' | 'daily' | 'weekly' | 'cron'>(
|
||||
'interval',
|
||||
);
|
||||
|
||||
// Schedule data fields
|
||||
const [minutes, setMinutes] = useState('60');
|
||||
@@ -268,7 +257,11 @@ function CreateScheduleForm({
|
||||
case 'daily':
|
||||
return { hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
|
||||
case 'weekly':
|
||||
return { dayOfWeek: parseInt(dayOfWeek, 10), hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
|
||||
return {
|
||||
dayOfWeek: parseInt(dayOfWeek, 10),
|
||||
hour: parseInt(hour, 10),
|
||||
minute: parseInt(minute, 10),
|
||||
};
|
||||
case 'cron':
|
||||
return { expression: cronExpression };
|
||||
}
|
||||
@@ -301,7 +294,9 @@ function CreateScheduleForm({
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Action</Label>
|
||||
<Select value={action} onValueChange={(v) => setAction(v as typeof action)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="command">Run Command</SelectItem>
|
||||
<SelectItem value="power">Power Action</SelectItem>
|
||||
@@ -322,7 +317,9 @@ function CreateScheduleForm({
|
||||
/>
|
||||
) : (
|
||||
<Select value={payload} onValueChange={setPayload}>
|
||||
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start">Start</SelectItem>
|
||||
<SelectItem value="stop">Stop</SelectItem>
|
||||
@@ -337,8 +334,13 @@ function CreateScheduleForm({
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Schedule Type</Label>
|
||||
<Select value={scheduleType} onValueChange={(v) => setScheduleType(v as typeof scheduleType)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select
|
||||
value={scheduleType}
|
||||
onValueChange={(v) => setScheduleType(v as typeof scheduleType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interval">Interval</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
@@ -366,10 +368,14 @@ function CreateScheduleForm({
|
||||
<div className="col-span-2 grid gap-1.5">
|
||||
<Label>Day of Week</Label>
|
||||
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_OF_WEEK.map((day, i) => (
|
||||
<SelectItem key={day} value={String(i)}>{day}</SelectItem>
|
||||
<SelectItem key={day} value={String(i)}>
|
||||
{day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -8,7 +8,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
interface ServerDetail {
|
||||
@@ -244,9 +250,13 @@ export function ServerSettingsPage() {
|
||||
const [description, setDescription] = useState('');
|
||||
const [startupOverride, setStartupOverride] = useState('');
|
||||
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
|
||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>(
|
||||
'server.install.completed',
|
||||
);
|
||||
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['games'],
|
||||
@@ -290,7 +300,10 @@ export function ServerSettingsPage() {
|
||||
|
||||
const automationRunMutation = useMutation({
|
||||
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
||||
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
|
||||
api.post<AutomationRunResponse>(
|
||||
`/organizations/${orgId}/servers/${serverId}/automation/run`,
|
||||
body,
|
||||
),
|
||||
onSuccess: (response) => {
|
||||
setLastAutomationResult(response.result);
|
||||
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
||||
@@ -442,7 +455,7 @@ export function ServerSettingsPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{environmentFields.map((field, index) => (
|
||||
{environmentFields.map((field, index) =>
|
||||
field.isCustom ? (
|
||||
<div
|
||||
key={`custom-${index}`}
|
||||
@@ -477,7 +490,8 @@ export function ServerSettingsPage() {
|
||||
{field.label}
|
||||
</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Default: <span className="font-mono">{field.defaultValue || 'empty'}</span>
|
||||
Default:{' '}
|
||||
<span className="font-mono">{field.defaultValue || 'empty'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{field.inputType === 'boolean' ? (
|
||||
@@ -513,16 +527,13 @@ export function ServerSettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={saveStartupSettings}
|
||||
disabled={updateMutation.isPending || !server}
|
||||
>
|
||||
<Button onClick={saveStartupSettings} disabled={updateMutation.isPending || !server}>
|
||||
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -536,7 +547,10 @@ export function ServerSettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
|
||||
<Select
|
||||
value={automationEvent}
|
||||
onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -560,7 +574,9 @@ export function ServerSettingsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
|
||||
onClick={() =>
|
||||
automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })
|
||||
}
|
||||
disabled={automationRunMutation.isPending}
|
||||
>
|
||||
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
||||
@@ -601,8 +617,12 @@ export function ServerSettingsPage() {
|
||||
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
||||
<div className="space-y-1">
|
||||
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
||||
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
|
||||
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||
<p
|
||||
key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`}
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
[{failure.workflowId}
|
||||
{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -611,20 +631,22 @@ export function ServerSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
|
||||
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||
)}
|
||||
{automationRunMutation.isSuccess &&
|
||||
lastAutomationResult &&
|
||||
lastAutomationResult.failures.length === 0 && (
|
||||
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||
</p>
|
||||
)}
|
||||
{automationRunMutation.isSuccess &&
|
||||
lastAutomationResult &&
|
||||
lastAutomationResult.failures.length > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Failed to run automation event.
|
||||
</p>
|
||||
<p className="text-xs text-destructive">Failed to run automation event.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -639,7 +661,9 @@ export function ServerSettingsPage() {
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
|
||||
if (
|
||||
!window.confirm('Delete this server permanently? This action cannot be undone.')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate();
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -78,21 +84,14 @@ export function MembersPage() {
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberId: string) =>
|
||||
api.delete(`/organizations/${orgId}/members/${memberId}`),
|
||||
mutationFn: (memberId: string) => api.delete(`/organizations/${orgId}/members/${memberId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
memberId,
|
||||
preset,
|
||||
}: {
|
||||
memberId: string;
|
||||
preset: MembershipPreset;
|
||||
}) =>
|
||||
mutationFn: ({ memberId, preset }: { memberId: string; preset: MembershipPreset }) =>
|
||||
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
||||
onMutate: ({ memberId }) => {
|
||||
setUpdatingMemberId(memberId);
|
||||
|
||||
@@ -32,7 +32,11 @@ services:
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
||||
# -h forces a TCP probe. Without it pg_isready talks over the unix
|
||||
# socket, which answers during the image's init phase while the
|
||||
# server is not listening on 5432 yet — dependents then start and
|
||||
# get ECONNREFUSED from a container Compose just called healthy.
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+5
-1
@@ -23,7 +23,11 @@ services:
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
||||
# -h forces a TCP probe. Without it pg_isready talks over the unix
|
||||
# socket, which answers during the image's init phase while the
|
||||
# server is not listening on 5432 yet — dependents then start and
|
||||
# get ECONNREFUSED from a container Compose just called healthy.
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -23,17 +23,11 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const migrationsDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'drizzle',
|
||||
);
|
||||
const migrationsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'drizzle');
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(migrationsDir))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort();
|
||||
files = (await readdir(migrationsDir)).filter((file) => file.endsWith('.sql')).sort();
|
||||
} catch {
|
||||
console.log('No data migrations directory found, nothing to apply.');
|
||||
return;
|
||||
@@ -54,9 +48,7 @@ async function main() {
|
||||
)
|
||||
`);
|
||||
|
||||
const applied = await sql.unsafe<{ name: string }[]>(
|
||||
`SELECT name FROM ${MIGRATIONS_TABLE}`,
|
||||
);
|
||||
const applied = await sql.unsafe<{ name: string }[]>(`SELECT name FROM ${MIGRATIONS_TABLE}`);
|
||||
const appliedNames = new Set(applied.map((row) => row.name));
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
@@ -14,9 +14,20 @@ import { servers } from './servers';
|
||||
import { users } from './users';
|
||||
|
||||
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
||||
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', ['stable', 'beta', 'alpha']);
|
||||
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', ['file', 'zip']);
|
||||
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', ['installed', 'updating', 'failed']);
|
||||
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', [
|
||||
'stable',
|
||||
'beta',
|
||||
'alpha',
|
||||
]);
|
||||
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', [
|
||||
'file',
|
||||
'zip',
|
||||
]);
|
||||
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', [
|
||||
'installed',
|
||||
'updating',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export const plugins = pgTable('plugins', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
|
||||
@@ -12,12 +12,7 @@ import { servers } from './servers';
|
||||
|
||||
export const scheduleActionEnum = pgEnum('schedule_action', ['command', 'power', 'backup']);
|
||||
|
||||
export const scheduleTypeEnum = pgEnum('schedule_type', [
|
||||
'interval',
|
||||
'daily',
|
||||
'weekly',
|
||||
'cron',
|
||||
]);
|
||||
export const scheduleTypeEnum = pgEnum('schedule_type', ['interval', 'daily', 'weekly', 'cron']);
|
||||
|
||||
export const scheduledTasks = pgTable('scheduled_tasks', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { servers } from './servers';
|
||||
|
||||
export const serverDatabases = pgTable('server_databases', {
|
||||
|
||||
@@ -4,7 +4,5 @@
|
||||
const moduleUrl = (import.meta as ImportMeta & { url: string }).url;
|
||||
|
||||
export const PROTO_PATH = decodeURIComponent(
|
||||
moduleUrl
|
||||
.replace(/^file:\/\//, '')
|
||||
.replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
||||
moduleUrl.replace(/^file:\/\//, '').replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user