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