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