This commit is contained in:
hibna 2026-02-21 13:02:41 +03:00
commit 2215003a4d
59 changed files with 6100 additions and 0 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Database
DATABASE_URL=postgresql://gamepanel:gamepanel@localhost:5432/gamepanel
DB_USER=gamepanel
DB_PASSWORD=gamepanel
DB_NAME=gamepanel
# API
PORT=3000
HOST=0.0.0.0
CORS_ORIGIN=http://localhost:5173
# JWT
JWT_SECRET=change-me-in-production
JWT_REFRESH_SECRET=change-me-in-production-refresh
# Daemon
DAEMON_CONFIG=/etc/gamepanel/config.yml

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
node_modules/
dist/
.turbo/
*.log
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Rust
apps/daemon/target/
# Database
packages/database/drizzle/
# Common JS/TS
coverage/
.nyc_output/
.next/
out/
build/
.tsbuildinfo
*.tsbuildinfo
.pnpm-store/
# Claude
.claude/

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
@source:registry=https://gits.hibna.com.tr/api/packages/hibna/npm/
//gits.hibna.com.tr/api/packages/hibna/npm/:_authToken=c30642e24095b2e0f56936b17b2230c389d52e47

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.turbo
pnpm-lock.yaml
apps/daemon/target

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}

28
apps/api/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "@source/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/jwt": "^9.0.0",
"@fastify/websocket": "^11.0.0",
"@sinclair/typebox": "^0.34.0",
"@source/database": "workspace:*",
"@source/shared": "workspace:*",
"argon2": "^0.41.0",
"fastify": "^5.2.0",
"pino-pretty": "^13.0.0",
"socket.io": "^4.8.0"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}

33
apps/api/src/index.ts Normal file
View File

@ -0,0 +1,33 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
const app = Fastify({
logger: {
transport: {
target: 'pino-pretty',
},
},
});
await app.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
await app.register(cookie);
app.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`API server running on http://${HOST}:${PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

8
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

39
apps/daemon/Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "gamepanel-daemon"
version = "0.1.0"
edition = "2021"
description = "GamePanel daemon - manages game server containers"
[dependencies]
# gRPC
tonic = "0.12"
prost = "0.13"
prost-types = "0.13"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Docker
bollard = "0.18"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
# HTTP client (for CDN uploads, API callbacks)
reqwest = { version = "0.12", features = ["json"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
anyhow = "1"
thiserror = "2"
# UUID
uuid = { version = "1", features = ["v4"] }
[build-dependencies]
tonic-build = "0.12"

4
apps/daemon/build.rs Normal file
View File

@ -0,0 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("../../packages/proto/daemon.proto")?;
Ok(())
}

83
apps/daemon/src/config.rs Normal file
View File

@ -0,0 +1,83 @@
use anyhow::Result;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct DaemonConfig {
pub api_url: String,
pub node_token: String,
#[serde(default = "default_grpc_port")]
pub grpc_port: u16,
#[serde(default)]
pub docker: DockerConfig,
#[serde(default = "default_data_path")]
pub data_path: PathBuf,
#[serde(default = "default_backup_path")]
pub backup_path: PathBuf,
}
#[derive(Debug, Deserialize)]
pub struct DockerConfig {
#[serde(default = "default_docker_socket")]
pub socket: String,
#[serde(default = "default_docker_network")]
pub network: String,
#[serde(default = "default_docker_subnet")]
pub network_subnet: String,
}
impl Default for DockerConfig {
fn default() -> Self {
Self {
socket: default_docker_socket(),
network: default_docker_network(),
network_subnet: default_docker_subnet(),
}
}
}
fn default_grpc_port() -> u16 {
50051
}
fn default_data_path() -> PathBuf {
PathBuf::from("/var/lib/gamepanel/servers")
}
fn default_backup_path() -> PathBuf {
PathBuf::from("/var/lib/gamepanel/backups")
}
fn default_docker_socket() -> String {
"/var/run/docker.sock".to_string()
}
fn default_docker_network() -> String {
"gamepanel_nw".to_string()
}
fn default_docker_subnet() -> String {
"172.18.0.0/16".to_string()
}
impl DaemonConfig {
pub fn load() -> Result<Self> {
let config_path =
std::env::var("DAEMON_CONFIG").unwrap_or_else(|_| "/etc/gamepanel/config.yml".into());
let content = std::fs::read_to_string(&config_path)
.unwrap_or_else(|_| {
tracing::warn!("Config file not found at {}, using defaults", config_path);
// Minimal default config for development
r#"
api_url: "http://localhost:3000"
node_token: "dev-token"
grpc_port: 50051
"#
.to_string()
});
let config: DaemonConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
}

32
apps/daemon/src/main.rs Normal file
View File

@ -0,0 +1,32 @@
use anyhow::Result;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod config;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
info!("GamePanel Daemon starting...");
let config = config::DaemonConfig::load()?;
info!(grpc_port = config.grpc_port, "Configuration loaded");
// TODO: Initialize Docker client
// TODO: Start gRPC server
// TODO: Begin heartbeat loop
info!("GamePanel Daemon ready");
// Keep the process running
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
Ok(())
}

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GamePanel</title>
</head>
<body class="min-h-screen bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
apps/web/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@source/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint src/"
},
"dependencies": {
"@source/shared": "workspace:*",
"@source/ui": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.0",
"vite": "^6.0.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

33
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,33 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
retry: 1,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">GamePanel</h1>
<p className="mt-2 text-muted-foreground">Game Server Management Panel</p>
</div>
</div>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

55
apps/web/src/index.css Normal file
View File

@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

10
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,47 @@
import type { Config } from 'tailwindcss';
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
} satisfies Config;

14
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

25
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
},
});

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
services:
postgres:
image: postgres:16-alpine
container_name: gamepanel-postgres
ports:
- "5432:5432"
environment:
POSTGRES_USER: ${DB_USER:-gamepanel}
POSTGRES_PASSWORD: ${DB_PASSWORD:-gamepanel}
POSTGRES_DB: ${DB_NAME:-gamepanel}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: gamepanel-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

21
eslint.config.js Normal file
View File

@ -0,0 +1,21 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
ignores: ['**/dist/**', '**/node_modules/**', '**/.turbo/**', 'apps/daemon/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': 'warn',
},
},
);

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "source-gamepanel",
"version": "0.1.0",
"private": true,
"description": "Game server management panel",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,json,md}\"",
"db:generate": "turbo db:generate --filter=@source/database",
"db:migrate": "turbo db:migrate --filter=@source/database",
"db:seed": "turbo db:seed --filter=@source/database",
"db:studio": "turbo db:studio --filter=@source/database"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20.0.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.0",
"turbo": "^2.3.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0"
}
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@source/database",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx src/seed.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.38.0",
"postgres": "^3.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"drizzle-kit": "^0.30.0",
"tsx": "^4.19.0"
}
}

View File

@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
export function createDb(connectionString: string) {
const client = postgres(connectionString);
return drizzle(client, { schema });
}
export type Database = ReturnType<typeof createDb>;

View File

@ -0,0 +1,2 @@
export * from './schema';
export * from './client';

View File

@ -0,0 +1,19 @@
import { pgTable, uuid, varchar, jsonb, timestamp } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { users } from './users';
import { servers } from './servers';
export const auditLogs = pgTable('audit_logs', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id),
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
action: varchar('action', { length: 255 }).notNull(),
metadata: jsonb('metadata').default({}).notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,16 @@
import { pgTable, uuid, varchar, bigint, text, boolean, timestamp } from 'drizzle-orm/pg-core';
import { servers } from './servers';
export const backups = pgTable('backups', {
id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }),
cdnPath: text('cdn_path'),
checksum: text('checksum'),
isLocked: boolean('is_locked').default(false).notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,15 @@
import { pgTable, uuid, varchar, integer, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
export const games = pgTable('games', {
id: uuid('id').defaultRandom().primaryKey(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
dockerImage: text('docker_image').notNull(),
defaultPort: integer('default_port').notNull(),
configFiles: jsonb('config_files').default([]).notNull(),
startupCommand: text('startup_command').notNull(),
stopCommand: text('stop_command'),
environmentVars: jsonb('environment_vars').default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,9 @@
export * from './users';
export * from './organizations';
export * from './nodes';
export * from './games';
export * from './servers';
export * from './backups';
export * from './plugins';
export * from './schedules';
export * from './audit-logs';

View File

@ -0,0 +1,45 @@
import {
pgTable,
uuid,
varchar,
integer,
bigint,
text,
boolean,
timestamp,
} from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { servers } from './servers';
export const nodes = pgTable('nodes', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
fqdn: varchar('fqdn', { length: 255 }).notNull(),
daemonPort: integer('daemon_port').default(8443).notNull(),
grpcPort: integer('grpc_port').default(50051).notNull(),
location: varchar('location', { length: 255 }),
memoryTotal: bigint('memory_total', { mode: 'number' }).notNull(),
diskTotal: bigint('disk_total', { mode: 'number' }).notNull(),
memoryOveralloc: integer('memory_overalloc').default(0).notNull(),
diskOveralloc: integer('disk_overalloc').default(0).notNull(),
daemonToken: text('daemon_token').notNull(),
tlsEnabled: boolean('tls_enabled').default(true).notNull(),
isOnline: boolean('is_online').default(false).notNull(),
lastHeartbeat: timestamp('last_heartbeat', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const allocations = pgTable('allocations', {
id: uuid('id').defaultRandom().primaryKey(),
nodeId: uuid('node_id')
.notNull()
.references(() => nodes.id, { onDelete: 'cascade' }),
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
ip: varchar('ip', { length: 45 }).notNull(),
port: integer('port').notNull(),
isDefault: boolean('is_default').default(false).notNull(),
});

View File

@ -0,0 +1,30 @@
import { pgTable, uuid, varchar, integer, timestamp, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { users } from './users';
export const orgRoleEnum = pgEnum('org_role', ['admin', 'user']);
export const organizations = pgTable('organizations', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
slug: varchar('slug', { length: 255 }).notNull().unique(),
ownerId: uuid('owner_id')
.notNull()
.references(() => users.id),
maxServers: integer('max_servers').default(0).notNull(),
maxNodes: integer('max_nodes').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const organizationMembers = pgTable('organization_members', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: orgRoleEnum('role').default('user').notNull(),
customPermissions: jsonb('custom_permissions').default({}).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,42 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
timestamp,
pgEnum,
} from 'drizzle-orm/pg-core';
import { games } from './games';
import { servers } from './servers';
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
export const plugins = pgTable('plugins', {
id: uuid('id').defaultRandom().primaryKey(),
gameId: uuid('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
slug: varchar('slug', { length: 255 }).notNull(),
description: text('description'),
source: pluginSourceEnum('source').notNull(),
externalId: varchar('external_id', { length: 255 }),
downloadUrl: text('download_url'),
version: varchar('version', { length: 100 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const serverPlugins = pgTable('server_plugins', {
id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
pluginId: uuid('plugin_id')
.notNull()
.references(() => plugins.id, { onDelete: 'cascade' }),
installedVersion: varchar('installed_version', { length: 100 }),
isActive: boolean('is_active').default(true).notNull(),
installedAt: timestamp('installed_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,37 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
jsonb,
timestamp,
pgEnum,
} from 'drizzle-orm/pg-core';
import { servers } from './servers';
export const scheduleActionEnum = pgEnum('schedule_action', ['command', 'power', 'backup']);
export const scheduleTypeEnum = pgEnum('schedule_type', [
'interval',
'daily',
'weekly',
'cron',
]);
export const scheduledTasks = pgTable('scheduled_tasks', {
id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
action: scheduleActionEnum('action').notNull(),
payload: text('payload').notNull(),
scheduleType: scheduleTypeEnum('schedule_type').notNull(),
scheduleData: jsonb('schedule_data').notNull(),
isActive: boolean('is_active').default(true).notNull(),
lastRunAt: timestamp('last_run_at', { withTimezone: true }),
nextRunAt: timestamp('next_run_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,63 @@
import {
pgTable,
uuid,
varchar,
integer,
bigint,
text,
jsonb,
timestamp,
pgEnum,
} from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { nodes } from './nodes';
import { games } from './games';
import { users } from './users';
export const serverStatusEnum = pgEnum('server_status', [
'installing',
'running',
'stopped',
'suspended',
'error',
]);
export const servers = pgTable('servers', {
id: uuid('id').defaultRandom().primaryKey(),
uuid: varchar('uuid', { length: 36 }).notNull().unique(),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
nodeId: uuid('node_id')
.notNull()
.references(() => nodes.id),
gameId: uuid('game_id')
.notNull()
.references(() => games.id),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
status: serverStatusEnum('status').default('installing').notNull(),
memoryLimit: bigint('memory_limit', { mode: 'number' }).notNull(),
diskLimit: bigint('disk_limit', { mode: 'number' }).notNull(),
cpuLimit: integer('cpu_limit').default(100).notNull(),
port: integer('port').notNull(),
additionalPorts: jsonb('additional_ports').default([]).notNull(),
environment: jsonb('environment').default({}).notNull(),
startupOverride: text('startup_override'),
installedAt: timestamp('installed_at', { withTimezone: true }),
suspendedAt: timestamp('suspended_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const serverSubusers = pgTable('server_subusers', {
id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
permissions: jsonb('permissions').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,12 @@
import { pgTable, uuid, varchar, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
username: varchar('username', { length: 100 }).notNull().unique(),
passwordHash: text('password_hash').notNull(),
isSuperAdmin: boolean('is_super_admin').default(false).notNull(),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@ -0,0 +1,115 @@
import { createDb } from './client';
import { games } from './schema/games';
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
const db = createDb(databaseUrl);
console.log('Seeding games...');
await db
.insert(games)
.values([
{
slug: 'minecraft-java',
name: 'Minecraft: Java Edition',
dockerImage: 'itzg/minecraft-server:latest',
defaultPort: 25565,
startupCommand: '/start',
stopCommand: 'stop',
configFiles: JSON.stringify([
{
path: 'server.properties',
parser: 'properties',
editableKeys: [
'server-port',
'max-players',
'motd',
'difficulty',
'gamemode',
'level-seed',
'pvp',
'spawn-protection',
'view-distance',
'online-mode',
'white-list',
],
},
{ path: 'ops.json', parser: 'json' },
{ path: 'whitelist.json', parser: 'json' },
{ path: 'bukkit.yml', parser: 'yaml' },
{ path: 'spigot.yml', parser: 'yaml' },
]),
environmentVars: JSON.stringify([
{ key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true },
{
key: 'TYPE',
default: 'PAPER',
description: 'Server type (VANILLA, PAPER, SPIGOT, FORGE, FABRIC)',
required: true,
},
{
key: 'VERSION',
default: 'LATEST',
description: 'Minecraft version',
required: true,
},
{ key: 'MEMORY', default: '1G', description: 'JVM memory allocation', required: false },
]),
},
{
slug: 'cs2',
name: 'Counter-Strike 2',
dockerImage: 'cm2network/csgo:latest',
defaultPort: 27015,
startupCommand:
'./srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2',
stopCommand: 'quit',
configFiles: JSON.stringify([
{
path: 'csgo/cfg/server.cfg',
parser: 'keyvalue',
editableKeys: [
'hostname',
'sv_password',
'rcon_password',
'sv_cheats',
'mp_autoteambalance',
'mp_limitteams',
],
},
{ path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
]),
environmentVars: JSON.stringify([
{
key: 'SRCDS_TOKEN',
default: '',
description: 'Steam Game Server Login Token',
required: true,
},
{ key: 'SRCDS_RCONPW', default: '', description: 'RCON password', required: false },
{ key: 'SRCDS_PW', default: '', description: 'Server password', required: false },
{
key: 'SRCDS_MAXPLAYERS',
default: '16',
description: 'Max players',
required: false,
},
]),
},
])
.onConflictDoNothing();
console.log('Seed completed successfully!');
process.exit(0);
}
seed().catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

245
packages/proto/daemon.proto Normal file
View File

@ -0,0 +1,245 @@
syntax = "proto3";
package gamepanel.daemon;
// Empty message for parameterless RPCs
message Empty {}
// === Node ===
message NodeStatus {
string version = 1;
bool is_healthy = 2;
int64 uptime_seconds = 3;
int32 active_servers = 4;
}
message NodeStats {
double cpu_percent = 1;
int64 memory_used = 2;
int64 memory_total = 3;
int64 disk_used = 4;
int64 disk_total = 5;
}
// === Server Lifecycle ===
message ServerIdentifier {
string uuid = 1;
}
message PortMapping {
int32 host_port = 1;
int32 container_port = 2;
string protocol = 3; // "tcp" or "udp"
}
message CreateServerRequest {
string uuid = 1;
string docker_image = 2;
int64 memory_limit = 3;
int64 disk_limit = 4;
int32 cpu_limit = 5;
string startup_command = 6;
map<string, string> environment = 7;
repeated PortMapping ports = 8;
repeated string install_plugin_urls = 9;
}
message ServerResponse {
string uuid = 1;
string status = 2;
}
// === Power ===
enum PowerAction {
START = 0;
STOP = 1;
RESTART = 2;
KILL = 3;
}
message PowerRequest {
string uuid = 1;
PowerAction action = 2;
}
// === Server Status ===
message ServerStatus {
string uuid = 1;
string state = 2;
double cpu_percent = 3;
int64 memory_bytes = 4;
int64 disk_bytes = 5;
int64 network_rx = 6;
int64 network_tx = 7;
int64 uptime_seconds = 8;
}
message ServerResourceStats {
string uuid = 1;
double cpu_percent = 2;
int64 memory_bytes = 3;
int64 disk_bytes = 4;
int64 network_rx = 5;
int64 network_tx = 6;
string state = 7;
}
// === Console ===
message ConsoleOutput {
string uuid = 1;
string line = 2;
int64 timestamp = 3;
}
message CommandRequest {
string uuid = 1;
string command = 2;
}
// === Files ===
message FileEntry {
string name = 1;
string path = 2;
bool is_directory = 3;
int64 size = 4;
int64 modified_at = 5;
string mime_type = 6;
}
message FileListRequest {
string uuid = 1;
string path = 2;
}
message FileListResponse {
repeated FileEntry files = 1;
}
message FileReadRequest {
string uuid = 1;
string path = 2;
}
message FileContent {
bytes data = 1;
string mime_type = 2;
}
message FileWriteRequest {
string uuid = 1;
string path = 2;
bytes data = 3;
}
message FileDeleteRequest {
string uuid = 1;
repeated string paths = 2;
}
message CompressRequest {
string uuid = 1;
repeated string paths = 2;
string destination = 3;
}
message DecompressRequest {
string uuid = 1;
string path = 2;
string destination = 3;
}
// === Backup ===
message BackupRequest {
string server_uuid = 1;
string backup_id = 2;
string cdn_upload_url = 3;
}
message BackupResponse {
string backup_id = 1;
int64 size_bytes = 2;
string checksum = 3;
bool success = 4;
}
message BackupIdentifier {
string server_uuid = 1;
string backup_id = 2;
}
message RestoreBackupRequest {
string server_uuid = 1;
string backup_id = 2;
string cdn_download_url = 3;
}
// === Players ===
message Player {
string name = 1;
string uuid = 2;
int64 connected_at = 3;
}
message PlayerList {
repeated Player players = 1;
int32 max_players = 2;
}
// === Install Progress ===
message InstallProgress {
string uuid = 1;
int32 percent = 2;
string message = 3;
}
// === Service Definition ===
service DaemonService {
// Node
rpc GetNodeStatus(Empty) returns (NodeStatus);
rpc StreamNodeStats(Empty) returns (stream NodeStats);
// Server lifecycle
rpc CreateServer(CreateServerRequest) returns (ServerResponse);
rpc DeleteServer(ServerIdentifier) returns (Empty);
rpc ReinstallServer(ServerIdentifier) returns (Empty);
// Power
rpc SetPowerState(PowerRequest) returns (Empty);
rpc GetServerStatus(ServerIdentifier) returns (ServerStatus);
// Console
rpc StreamConsole(ServerIdentifier) returns (stream ConsoleOutput);
rpc SendCommand(CommandRequest) returns (Empty);
// Files
rpc ListFiles(FileListRequest) returns (FileListResponse);
rpc ReadFile(FileReadRequest) returns (FileContent);
rpc WriteFile(FileWriteRequest) returns (Empty);
rpc DeleteFiles(FileDeleteRequest) returns (Empty);
rpc CompressFiles(CompressRequest) returns (FileContent);
rpc DecompressFile(DecompressRequest) returns (Empty);
// Backup
rpc CreateBackup(BackupRequest) returns (BackupResponse);
rpc RestoreBackup(RestoreBackupRequest) returns (Empty);
rpc DeleteBackup(BackupIdentifier) returns (Empty);
// Stats
rpc StreamServerStats(ServerIdentifier) returns (stream ServerResourceStats);
// Install progress
rpc StreamInstallProgress(ServerIdentifier) returns (stream InstallProgress);
// Players
rpc GetActivePlayers(ServerIdentifier) returns (PlayerList);
}

View File

@ -0,0 +1,12 @@
{
"name": "@source/proto",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"generate": "buf generate"
}
}

View File

@ -0,0 +1,4 @@
// Proto generated types will be exported here after running `pnpm generate`
// For now, this is a placeholder
export const PROTO_PATH = new URL('../daemon.proto', import.meta.url).pathname;

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@source/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"build": "tsc"
}
}

View File

@ -0,0 +1,3 @@
export * from './permissions';
export * from './roles';
export * from './types';

View File

@ -0,0 +1,57 @@
export const PERMISSIONS = {
// Server
'server.create': 'Create servers',
'server.read': 'View servers',
'server.update': 'Update server settings',
'server.delete': 'Delete servers',
// Console
'console.read': 'View console output',
'console.write': 'Send console commands',
// Files
'files.read': 'View and download files',
'files.write': 'Create, edit, and upload files',
'files.delete': 'Delete files',
'files.archive': 'Compress and decompress files',
// Backup
'backup.create': 'Create backups',
'backup.restore': 'Restore backups',
'backup.delete': 'Delete backups',
'backup.download': 'Download backups',
// Schedule
'schedule.read': 'View scheduled tasks',
'schedule.manage': 'Create, edit, and delete scheduled tasks',
// Subuser
'subuser.read': 'View subusers',
'subuser.manage': 'Create, edit, and delete subusers',
// Plugin
'plugin.read': 'View installed plugins',
'plugin.manage': 'Install and remove plugins',
// Config
'config.read': 'View server config',
'config.write': 'Edit server config',
// Power
'power.start': 'Start server',
'power.stop': 'Stop server',
'power.restart': 'Restart server',
'power.kill': 'Kill server',
// Node (org admin)
'node.read': 'View nodes',
'node.manage': 'Manage nodes and allocations',
// Organization
'org.settings': 'Manage organization settings',
'org.members': 'Manage organization members',
} as const;
export type Permission = keyof typeof PERMISSIONS;
export const ALL_PERMISSIONS = Object.keys(PERMISSIONS) as Permission[];

View File

@ -0,0 +1,29 @@
import type { Permission } from './permissions';
import { ALL_PERMISSIONS } from './permissions';
export const ROLES = {
admin: {
name: 'Admin',
description: 'Full access to the organization',
permissions: ALL_PERMISSIONS,
},
user: {
name: 'User',
description: 'Limited access, must be granted server-specific permissions',
permissions: [
'server.read',
'console.read',
'files.read',
'backup.create',
'backup.download',
'schedule.read',
'plugin.read',
'config.read',
'power.start',
'power.stop',
'power.restart',
] as Permission[],
},
} as const;
export type Role = keyof typeof ROLES;

View File

@ -0,0 +1,24 @@
export type ServerStatus = 'installing' | 'running' | 'stopped' | 'suspended' | 'error';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
export type ScheduleType = 'interval' | 'daily' | 'weekly' | 'cron';
export type ScheduleAction = 'command' | 'power' | 'backup';
export type PluginSource = 'spiget' | 'manual';
export interface PaginationParams {
page: number;
perPage: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
perPage: number;
total: number;
totalPages: number;
};
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

21
packages/ui/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "@source/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"build": "tsc"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.6.0"
}
}

1
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1 @@
export { cn } from './lib/utils';

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

4540
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

20
tsconfig.base.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noEmit": true
},
"exclude": ["node_modules", "dist"]
}

29
turbo.json Normal file
View File

@ -0,0 +1,29 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"db:generate": {
"cache": false
},
"db:migrate": {
"cache": false
},
"db:seed": {
"cache": false
},
"db:studio": {
"cache": false,
"persistent": true
}
}
}