5 Commits

Author SHA1 Message Date
hibna d50a7fd049 Install the docker CLI in jobs that build images
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m4s
CI / Daemon Build & Test (push) Successful in 6m19s
CI / Publish images (push) Failing after 2m15s
The runner executes jobs inside a container that ships no docker client,
so the publish job died on its first command:

  /var/run/act/workflow/1: line 3: docker: command not found

act_runner does mount the host's socket into job containers, so only the
client is missing. Both image jobs now fetch the static binary when it is
absent and then check that the socket answers, because "no client" and
"no daemon" are different problems and the log should say which one it
hit. The docker build-test job needed the same treatment — it is skipped
on tags, so it had never reached that command either.

Verified in a container without a docker client: with the socket mounted
the step installs the client and builds the web image; without it the
step fails with the runner-configuration message instead of a confusing
connection error.
2026-08-02 23:13:55 +03:00
hibna 55e0a3cde6 Make the API and web images build and run
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m13s
CI / Daemon Build & Test (push) Successful in 6m24s
CI / Publish images (push) Failing after 12s
The image builds were never exercised: the docker job needs lint, and
lint was failing, so nothing downstream of it ever ran. Four defects
had accumulated behind that gate, each fatal on its own.

- pnpm creates no node_modules for a package without dependencies, and
  @source/shared has none. Three COPY lines named that path and failed.
- The production stage copied apps/api/dist, which tsc never wrote:
  tsconfig.base.json sets noEmit and no package overrides it. Rather
  than turn emit on — every @source/* package points main at its
  TypeScript source, and @source/proto derives daemon.proto's location
  from a /src/index.ts module URL — the stage now runs the sources
  through tsx, exactly as the migrate stage has always done.
- tsx lives in apps/api/node_modules/.bin under pnpm's isolated layout,
  so the command only resolves from the package directory.
- Both healthchecks probed localhost, which musl resolves to ::1 while
  the servers bind IPv4. Every probe was refused, so the containers sat
  unhealthy forever — and `docker compose up --wait`, which is how a
  panel installs this stack, waits for healthy.

Also fixed the postgres healthcheck in both compose files. pg_isready
without -h asks over the unix socket, which answers during the image's
init phase before the server listens on TCP; the migrate container then
started and died with ECONNREFUSED against a container Compose had just
called healthy.

Verified by running the full stack from docker-compose.panel.yml with
locally built images: migrations and seed complete, api, web, daemon,
postgres and redis all report healthy, and /api/health answers 200
through the web container's proxy.
2026-08-02 21:48:51 +03:00
hibna cb3a90be35 Build the daemon with Rust 1.97
Dependencies now ship edition 2024, which Cargo 1.83 refuses to parse:

  feature `edition2024` is required
  ... not stabilized in this version of Cargo (1.83.0)

The pin lived in two places and both had to move, or the daemon image
would have failed the same way the CI job did.
2026-08-02 21:48:33 +03:00
hibna 276150a769 Install protoc without sudo on self-hosted runners
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m35s
CI / Daemon Build & Test (push) Failing after 2m27s
CI / Publish images (push) Has been skipped
The daemon job assumed a GitHub-hosted runner, where the build user is
unprivileged and sudo exists. Our act runner is a container that runs
as root and ships no sudo, so the step died with "sudo: command not
found" before the toolchain was ever installed — which also blocked the
publish job that waits on it.

Use sudo only when we are not already root, so the step works on both.
2026-08-02 21:08:24 +03:00
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

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