mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: NovaCPX v1.0.0 initial scaffold
Full hosting control panel with 3 tiers: Admin, Reseller, User. - install.sh: unattended installer for Ubuntu 20/22/24 + Debian 11/12 - PHP multi-version (7.4/8.1/8.2/8.3), Apache2/nginx choice, MySQL, PostgreSQL - BIND9 DNS, Postfix+Dovecot mail, ProFTPD, Certbot SSL, UFW, Fail2Ban - 18-table DB schema with audit log and version tracking - PHP REST API (auth, system/updates, server stats, service control) - Admin panel: dark dashboard, service manager, git-based update system - User panel: usage rings + feature card grid (distinct from cPanel) - VERSION file: git-tracked; Admin > Updates panel shows/applies git commits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
match ($action) {
|
||||
'login' => (function() use ($body) {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
if (!$username || !$password) Response::error('Username and password required');
|
||||
$auth = Auth::getInstance();
|
||||
$token = $auth->attempt($username, $password);
|
||||
if (!$token) Response::error('Invalid credentials', 401);
|
||||
$user = $auth->user();
|
||||
audit('login', 'auth');
|
||||
Response::success([
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
'theme' => $user['theme'],
|
||||
],
|
||||
], 'Login successful');
|
||||
})(),
|
||||
|
||||
'logout' => (function() {
|
||||
Auth::getInstance()->logout();
|
||||
audit('logout', 'auth');
|
||||
Response::success(null, 'Logged out');
|
||||
})(),
|
||||
|
||||
'me' => (function() use ($currentUser) {
|
||||
Response::success([
|
||||
'id' => $currentUser['uid'],
|
||||
'username' => $currentUser['username'],
|
||||
'email' => $currentUser['email'],
|
||||
'role' => $currentUser['role'],
|
||||
'theme' => $currentUser['theme'],
|
||||
]);
|
||||
})(),
|
||||
|
||||
default => Response::error('Unknown auth action', 404),
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* System endpoint — version info, updates, server stats, services
|
||||
* Admin-only actions gated with Auth::require('admin')
|
||||
*/
|
||||
|
||||
Auth::getInstance()->require('admin', 'reseller', 'user');
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
match ($action) {
|
||||
|
||||
// ── Version & Update Info ─────────────────────────────────────────────────
|
||||
'version' => (function() use ($db) {
|
||||
$installed = $db->fetchOne("SELECT version, installed_at, git_commit FROM novacpx_version ORDER BY id DESC LIMIT 1");
|
||||
$gitDir = NOVACPX_ROOT . '/.git';
|
||||
$gitCommit = null;
|
||||
$gitBranch = null;
|
||||
$gitDirty = false;
|
||||
|
||||
if (is_dir($gitDir)) {
|
||||
$gitCommit = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --short HEAD 2>/dev/null") ?: '');
|
||||
$gitBranch = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --abbrev-ref HEAD 2>/dev/null") ?: '');
|
||||
$status = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " status --porcelain 2>/dev/null");
|
||||
$gitDirty = !empty(trim($status));
|
||||
}
|
||||
|
||||
Response::success([
|
||||
'installed_version' => $installed['version'] ?? NOVACPX_VERSION,
|
||||
'installed_at' => $installed['installed_at'],
|
||||
'git_commit' => $gitCommit ?: ($installed['git_commit'] ?? null),
|
||||
'git_branch' => $gitBranch,
|
||||
'git_dirty' => $gitDirty,
|
||||
'php_version' => PHP_VERSION,
|
||||
'os' => php_uname('s') . ' ' . php_uname('r'),
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Check for updates ─────────────────────────────────────────────────────
|
||||
'check-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$remote = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'git_remote'");
|
||||
$gitRemote = $remote['value'] ?? '';
|
||||
if (!$gitRemote) Response::error('No git remote configured');
|
||||
|
||||
$output = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " fetch origin 2>&1 && git -C " . escapeshellarg(NOVACPX_ROOT) . " log HEAD..origin/main --oneline 2>/dev/null");
|
||||
$updates = array_values(array_filter(explode("\n", trim($output ?: ''))));
|
||||
|
||||
Response::success([
|
||||
'updates_available' => count($updates),
|
||||
'commits' => $updates,
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Apply update ──────────────────────────────────────────────────────────
|
||||
'apply-update' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$before = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse HEAD 2>/dev/null") ?: '');
|
||||
|
||||
$pull = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " pull origin main 2>&1");
|
||||
|
||||
$after = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse HEAD 2>/dev/null") ?: '');
|
||||
$changed = $before !== $after;
|
||||
|
||||
if ($changed) {
|
||||
// Run any pending DB migrations
|
||||
$migrDir = NOVACPX_ROOT . '/db/migrations';
|
||||
if (is_dir($migrDir)) {
|
||||
foreach (glob("$migrDir/*.sql") as $sql) {
|
||||
$migName = basename($sql, '.sql');
|
||||
$already = $db->fetchOne("SELECT 1 FROM settings WHERE `key` = 'migration_$migName'");
|
||||
if (!$already) {
|
||||
$db->pdo()->exec(file_get_contents($sql));
|
||||
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()", ["migration_$migName"]);
|
||||
novacpx_log('info', "Migration applied: $migName");
|
||||
}
|
||||
}
|
||||
}
|
||||
audit('system.update', "novacpx:$before→$after");
|
||||
novacpx_log('info', "NovaCPX updated $before → $after");
|
||||
}
|
||||
|
||||
Response::success([
|
||||
'updated' => $changed,
|
||||
'from_commit' => $before,
|
||||
'to_commit' => $after,
|
||||
'pull_output' => $pull,
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Server Stats ──────────────────────────────────────────────────────────
|
||||
'stats' => (function() use ($db) {
|
||||
// CPU/load
|
||||
$load = sys_getloadavg();
|
||||
$cpuPct = round(($load[0] / max(1, (int)shell_exec('nproc'))) * 100, 1);
|
||||
|
||||
// RAM
|
||||
$memRaw = file_get_contents('/proc/meminfo');
|
||||
preg_match('/MemTotal:\s+(\d+)/', $memRaw, $mt);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $memRaw, $ma);
|
||||
$ramTotal = (int)($mt[1] ?? 0);
|
||||
$ramAvail = (int)($ma[1] ?? 0);
|
||||
$ramPct = $ramTotal > 0 ? round((($ramTotal - $ramAvail) / $ramTotal) * 100, 1) : 0;
|
||||
|
||||
// Disk
|
||||
$diskTotal = disk_total_space('/');
|
||||
$diskFree = disk_free_space('/');
|
||||
$diskPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0;
|
||||
|
||||
// Services
|
||||
$services = [];
|
||||
foreach (['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban'] as $svc) {
|
||||
$active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: '');
|
||||
if ($active) $services[$svc] = $active;
|
||||
}
|
||||
|
||||
// Persist to DB for history
|
||||
$db->execute(
|
||||
"INSERT INTO server_stats (cpu_pct,ram_pct,disk_pct,load_1m,load_5m,load_15m) VALUES (?,?,?,?,?,?)",
|
||||
[$cpuPct, $ramPct, $diskPct, $load[0], $load[1], $load[2]]
|
||||
);
|
||||
|
||||
Response::success([
|
||||
'cpu' => ['pct' => $cpuPct, 'load' => $load],
|
||||
'ram' => ['total_kb' => $ramTotal, 'used_kb' => $ramTotal - $ramAvail, 'pct' => $ramPct],
|
||||
'disk' => ['total' => $diskTotal, 'free' => $diskFree, 'pct' => $diskPct],
|
||||
'services' => $services,
|
||||
'uptime' => trim(shell_exec('uptime -p') ?: ''),
|
||||
]);
|
||||
})(),
|
||||
|
||||
// ── Service control (start/stop/restart) ──────────────────────────────────
|
||||
'service' => (function() use ($body, $db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$svc = preg_replace('/[^a-z0-9\-_]/', '', $body['service'] ?? '');
|
||||
$cmd = $body['command'] ?? 'status';
|
||||
$allowed = ['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban','php7.4-fpm','php8.1-fpm','php8.2-fpm','php8.3-fpm'];
|
||||
if (!in_array($svc, $allowed)) Response::error("Service not managed: $svc");
|
||||
if (!in_array($cmd, ['start','stop','restart','reload','status'])) Response::error("Invalid command");
|
||||
|
||||
$out = shell_exec("systemctl $cmd " . escapeshellarg($svc) . " 2>&1");
|
||||
audit("service.$cmd", $svc);
|
||||
Response::success(['output' => $out]);
|
||||
})(),
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
'audit-log' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log")['c'] ?? 0;
|
||||
$rows = $db->fetchAll("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset]);
|
||||
Response::paginate($rows, (int)$total, $page, $perPage);
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown system action: $action", 404),
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* NovaCPX API Router
|
||||
* All requests: /api/{endpoint}/{action}
|
||||
*/
|
||||
|
||||
define('NOVACPX_ROOT', dirname(__DIR__, 2));
|
||||
define('NOVACPX_API', __DIR__);
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT . '/panel/lib');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('X-NovaCPX-Version: ' . (file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
|
||||
// CORS for same-origin panel requests
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (preg_match('#^https?://[^/]+:2083$#', $origin)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
require_once NOVACPX_LIB . '/Core.php';
|
||||
require_once NOVACPX_LIB . '/Auth.php';
|
||||
require_once NOVACPX_LIB . '/DB.php';
|
||||
require_once NOVACPX_LIB . '/Response.php';
|
||||
|
||||
// Parse route: /api/endpoint/action
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$parts = array_values(array_filter(explode('/', $uri)));
|
||||
$apiIdx = array_search('api', $parts);
|
||||
$endpoint = $parts[$apiIdx + 1] ?? null;
|
||||
$action = $parts[$apiIdx + 2] ?? null;
|
||||
|
||||
if (!$endpoint) {
|
||||
Response::json(['status' => 'ok', 'panel' => 'NovaCPX', 'version' => NOVACPX_VERSION]);
|
||||
}
|
||||
|
||||
// Public endpoints (no auth required)
|
||||
$public = ['auth'];
|
||||
if (!in_array($endpoint, $public)) {
|
||||
$auth = Auth::getInstance();
|
||||
if (!$auth->check()) {
|
||||
Response::error('Unauthorized', 401);
|
||||
}
|
||||
$currentUser = $auth->user();
|
||||
}
|
||||
|
||||
// Route to endpoint handler
|
||||
$endpointFile = NOVACPX_API . "/endpoints/{$endpoint}.php";
|
||||
if (!file_exists($endpointFile)) {
|
||||
Response::error("Unknown endpoint: $endpoint", 404);
|
||||
}
|
||||
|
||||
require $endpointFile;
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
class Auth {
|
||||
private static ?Auth $instance = null;
|
||||
private ?array $user = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function getInstance(): self {
|
||||
if (!self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function check(): bool {
|
||||
// Bearer token (API)
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
$token = substr($header, 7);
|
||||
return $this->loginByToken($token);
|
||||
}
|
||||
// Session cookie
|
||||
$sessionId = $_COOKIE['ncpx_session'] ?? '';
|
||||
if ($sessionId) {
|
||||
return $this->loginBySession($sessionId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function loginBySession(string $sessionId): bool {
|
||||
$db = DB::getInstance();
|
||||
$row = $db->fetchOne(
|
||||
"SELECT s.*, u.id as uid, u.username, u.email, u.role, u.status, u.reseller_id, u.theme
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'",
|
||||
[hash('sha256', $sessionId)]
|
||||
);
|
||||
if (!$row) return false;
|
||||
$this->user = $row;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginByToken(string $token): bool {
|
||||
$db = DB::getInstance();
|
||||
$row = $db->fetchOne(
|
||||
"SELECT t.permissions, u.id as uid, u.username, u.email, u.role, u.status
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW()) AND u.status = 'active'",
|
||||
[hash('sha256', $token)]
|
||||
);
|
||||
if (!$row) return false;
|
||||
$db->execute("UPDATE api_tokens SET last_used = NOW() WHERE token = ?", [hash('sha256', $token)]);
|
||||
$this->user = $row;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attempt(string $username, string $password): ?string {
|
||||
$db = DB::getInstance();
|
||||
$user = $db->fetchOne(
|
||||
"SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'",
|
||||
[$username, $username]
|
||||
);
|
||||
if (!$user || !password_verify($password, $user['password'])) return null;
|
||||
|
||||
// Create session
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$sessionId = hash('sha256', $token);
|
||||
$db->execute(
|
||||
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR))",
|
||||
[$sessionId, $user['id'], $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? '']
|
||||
);
|
||||
$db->execute("UPDATE users SET last_login = NOW() WHERE id = ?", [$user['id']]);
|
||||
|
||||
setcookie('ncpx_session', $token, [
|
||||
'expires' => time() + 28800,
|
||||
'path' => '/',
|
||||
'secure' => true,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
$this->user = $user;
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function logout(): void {
|
||||
$sessionId = hash('sha256', $_COOKIE['ncpx_session'] ?? '');
|
||||
DB::getInstance()->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]);
|
||||
setcookie('ncpx_session', '', time() - 3600, '/', '', true, true);
|
||||
}
|
||||
|
||||
public function user(): ?array { return $this->user; }
|
||||
|
||||
public function require(string ...$roles): void {
|
||||
$user = $this->user();
|
||||
if (!$user || !in_array($user['role'], $roles)) {
|
||||
Response::error('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* NovaCPX Core — constants, config loader, helpers
|
||||
*/
|
||||
|
||||
define('NOVACPX_VERSION', trim(file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
define('NOVACPX_CONFIG', '/etc/novacpx/config.ini');
|
||||
|
||||
// Load config
|
||||
$_cfg = parse_ini_file(NOVACPX_CONFIG, true);
|
||||
if (!$_cfg) {
|
||||
http_response_code(503);
|
||||
die(json_encode(['error' => 'NovaCPX not configured. Run the installer.']));
|
||||
}
|
||||
|
||||
define('DB_HOST', $_cfg['database']['host'] ?? 'localhost');
|
||||
define('DB_NAME', $_cfg['database']['name'] ?? 'novacpx');
|
||||
define('DB_USER', $_cfg['database']['user'] ?? '');
|
||||
define('DB_PASS', $_cfg['database']['pass'] ?? '');
|
||||
define('SECRET_KEY', $_cfg['panel']['secret'] ?? '');
|
||||
define('PANEL_VER', $_cfg['panel']['version'] ?? NOVACPX_VERSION);
|
||||
define('WEB_SERVER', $_cfg['web']['server'] ?? 'apache');
|
||||
define('PHP_DEFAULT',$_cfg['web']['php_default'] ?? '8.3');
|
||||
|
||||
function novacpx_log(string $level, string $msg, array $ctx = []): void {
|
||||
$line = sprintf("[%s] [%s] %s %s\n",
|
||||
date('Y-m-d H:i:s'), strtoupper($level), $msg,
|
||||
$ctx ? json_encode($ctx) : ''
|
||||
);
|
||||
file_put_contents('/var/log/novacpx/panel.log', $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
function audit(string $action, string $resource = '', array $detail = []): void {
|
||||
try {
|
||||
$db = DB::getInstance();
|
||||
$auth = Auth::getInstance();
|
||||
$user = $auth->user();
|
||||
$db->execute(
|
||||
"INSERT INTO audit_log (user_id, username, action, resource, detail, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
$user['id'] ?? null,
|
||||
$user['username'] ?? 'system',
|
||||
$action,
|
||||
$resource,
|
||||
json_encode($detail),
|
||||
$_SERVER['REMOTE_ADDR'] ?? '',
|
||||
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
novacpx_log('error', 'audit failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
class DB {
|
||||
private static ?DB $instance = null;
|
||||
private PDO $pdo;
|
||||
|
||||
private function __construct() {
|
||||
$this->pdo = new PDO(
|
||||
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
|
||||
DB_USER, DB_PASS,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static function getInstance(): self {
|
||||
if (!self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): PDOStatement {
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function fetchOne(string $sql, array $params = []): ?array {
|
||||
return $this->execute($sql, $params)->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array {
|
||||
return $this->execute($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
public function insert(string $sql, array $params = []): string {
|
||||
$this->execute($sql, $params);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function pdo(): PDO { return $this->pdo; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
class Response {
|
||||
public static function json(array $data, int $code = 200): never {
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'OK'): never {
|
||||
self::json(['success' => true, 'message' => $message, 'data' => $data]);
|
||||
}
|
||||
|
||||
public static function error(string $message, int $code = 400, array $errors = []): never {
|
||||
self::json(['success' => false, 'message' => $message, 'errors' => $errors], $code);
|
||||
}
|
||||
|
||||
public static function paginate(array $items, int $total, int $page, int $perPage): never {
|
||||
self::json([
|
||||
'success' => true,
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'pages' => (int) ceil($total / $perPage),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Options -Indexes
|
||||
RewriteEngine On
|
||||
|
||||
# Route API calls
|
||||
RewriteRule ^api/(.*)$ api/index.php [QSA,L]
|
||||
|
||||
# Panel routes — serve index.php for SPA navigation within each panel tier
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(admin|reseller|user)/.*$ $1/index.php [QSA,L]
|
||||
|
||||
# Security headers
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
// NovaCPX Admin Panel — Datacenter/Server Manager
|
||||
// Equivalent to WHM (WebHost Manager)
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Overview</div>
|
||||
<a href="#" class="sidebar-link active" data-page="dashboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="server-status">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Server Status
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Accounts</div>
|
||||
<a href="#" class="sidebar-link" data-page="accounts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
All Accounts
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="resellers">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M2 20c0-4 4-7 10-7s10 3 10 7"/></svg>
|
||||
Resellers
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="packages">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
Packages
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="create-account">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">DNS</div>
|
||||
<a href="#" class="sidebar-link" data-page="dns-zones">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
DNS Zones
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="nameservers">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Nameservers
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Services</div>
|
||||
<a href="#" class="sidebar-link" data-page="web-server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
Web Server
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="php-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
PHP Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="mysql-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
MySQL / PgSQL
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="mail-server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Mail Server
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="ftp-server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
FTP Server
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Security</div>
|
||||
<a href="#" class="sidebar-link" data-page="ssl-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
SSL Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="firewall">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Firewall / Fail2Ban
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="audit-log">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">System</div>
|
||||
<a href="#" class="sidebar-link" data-page="updates">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
Updates <span id="update-badge" class="badge badge-yellow" style="display:none"></span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="backups">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Backups
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">A</div>
|
||||
<div>
|
||||
<div class="user-name" id="user-name">Admin</div>
|
||||
<div class="user-role">Administrator</div>
|
||||
</div>
|
||||
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
|
||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="server-ip" class="text-muted text-sm"></span>
|
||||
<div id="alert-indicator" style="display:none">
|
||||
<span class="badge badge-red" id="alert-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content" id="page-content">
|
||||
<!-- Loaded by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth guard -->
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh">
|
||||
<div style="text-align:center;color:var(--text-muted)">Verifying session…</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/nova.js"></script>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,302 @@
|
||||
/* NovaCPX Design System */
|
||||
:root {
|
||||
--bg: #0d0f17;
|
||||
--bg2: #131520;
|
||||
--bg3: #1a1d2e;
|
||||
--border: #252840;
|
||||
--text: #e2e4f0;
|
||||
--text-muted: #7c7f9a;
|
||||
--primary: #6366f1;
|
||||
--primary-h: #4f52e8;
|
||||
--sky: #0ea5e9;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,.4);
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { font-size: 15px; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Login Page ─────────────────────────────────────────────────────────────── */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; }
|
||||
|
||||
.login-brand {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
justify-content: center; margin-bottom: 2rem;
|
||||
}
|
||||
.logo-icon { width: 42px; height: 42px; }
|
||||
.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; }
|
||||
.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
.login-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; }
|
||||
.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; }
|
||||
|
||||
.login-footer {
|
||||
text-align: center; margin-top: 1.25rem;
|
||||
font-size: .8rem; color: var(--text-muted);
|
||||
}
|
||||
.login-footer a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="email"],
|
||||
input[type="number"], input[type="url"], select, textarea {
|
||||
width: 100%; padding: .65rem .9rem;
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
font-family: var(--font); font-size: .9rem;
|
||||
transition: border-color .15s;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
|
||||
|
||||
.input-with-icon { position: relative; }
|
||||
.input-with-icon input { padding-right: 2.5rem; }
|
||||
.eye-toggle {
|
||||
position: absolute; right: .75rem; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: 0; display: flex; align-items: center;
|
||||
}
|
||||
.eye-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
padding: .6rem 1.25rem; border: none; border-radius: var(--radius);
|
||||
font-family: var(--font); font-size: .9rem; font-weight: 500;
|
||||
cursor: pointer; transition: all .15s; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-h); }
|
||||
.btn-sky { background: var(--sky); color: #fff; }
|
||||
.btn-green { background: var(--green); color: #fff; }
|
||||
.btn-red { background: var(--red); color: #fff; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-full { width: 100%; justify-content: center; padding: .75rem; }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-sm { padding: .35rem .8rem; font-size: .82rem; }
|
||||
.btn-icon { padding: .5rem; border-radius: 8px; }
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────────── */
|
||||
.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; }
|
||||
.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; }
|
||||
.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; }
|
||||
.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; }
|
||||
.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; }
|
||||
|
||||
/* ── Panel Layout ────────────────────────────────────────────────────────────── */
|
||||
.panel-layout { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 240px; min-width: 240px; background: var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: fixed; height: 100vh; overflow-y: auto; z-index: 100;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-brand .logo-text { font-size: 1.1rem; }
|
||||
.sidebar-brand .logo-icon { width: 28px; height: 28px; }
|
||||
|
||||
.sidebar-section { padding: .75rem 0; }
|
||||
.sidebar-section-label {
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
padding: .25rem 1.25rem .5rem;
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
padding: .55rem 1.25rem; text-decoration: none;
|
||||
color: var(--text-muted); font-size: .88rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all .12s;
|
||||
}
|
||||
.sidebar-link:hover { color: var(--text); background: var(--bg3); }
|
||||
.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); }
|
||||
.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.sidebar-user {
|
||||
margin-top: auto; padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-user-info { display: flex; align-items: center; gap: .75rem; }
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--sky));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .9rem; flex-shrink: 0;
|
||||
}
|
||||
.user-name { font-size: .88rem; font-weight: 600; }
|
||||
.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; }
|
||||
|
||||
.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.topbar {
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border);
|
||||
padding: .75rem 1.5rem; display: flex; align-items: center;
|
||||
gap: 1rem; position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: .5rem; }
|
||||
|
||||
.page-content { padding: 1.5rem; flex: 1; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
}
|
||||
.card-title { font-size: .95rem; font-weight: 600; flex: 1; }
|
||||
.card-body { padding: 1.25rem; }
|
||||
|
||||
/* ── Stats Cards ─────────────────────────────────────────────────────────────── */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1.25rem;
|
||||
}
|
||||
.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; }
|
||||
.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; }
|
||||
.stat-green { color: var(--green); }
|
||||
.stat-red { color: var(--red); }
|
||||
.stat-yellow{ color: var(--yellow); }
|
||||
.stat-blue { color: var(--sky); }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────────── */
|
||||
.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; }
|
||||
.progress-bar.green { background: var(--green); }
|
||||
.progress-bar.yellow { background: var(--yellow); }
|
||||
.progress-bar.red { background: var(--red); }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; }
|
||||
.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; }
|
||||
.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; }
|
||||
.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; }
|
||||
.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; }
|
||||
.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; }
|
||||
.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.7); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 14px; width: 100%; max-width: 500px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.6);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; }
|
||||
.modal-body { padding: 1.5rem; }
|
||||
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
||||
.tab-btn {
|
||||
padding: .65rem 1.25rem; border: none; background: none;
|
||||
color: var(--text-muted); font-size: .88rem; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color .12s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── Grid helpers ────────────────────────────────────────────────────────────── */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); transition: transform .2s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; }
|
||||
.grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Services status ─────────────────────────────────────────────────────────── */
|
||||
.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.service-dot.inactive { background: var(--red); }
|
||||
.service-dot.unknown { background: var(--text-muted); }
|
||||
|
||||
/* ── Code / Terminal ─────────────────────────────────────────────────────────── */
|
||||
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; }
|
||||
.terminal {
|
||||
background: #050508; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7;
|
||||
color: #a6e22e; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; }
|
||||
.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; }
|
||||
.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
|
||||
.text-right { text-align: right; } .font-bold { font-weight: 700; }
|
||||
.w-full { width: 100%; } .hidden { display: none; }
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* NovaCPX Admin Panel — page controllers
|
||||
*/
|
||||
(async () => {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────────
|
||||
const me = await Nova.api('auth', 'me');
|
||||
if (!me?.success || me.data.role !== 'admin') {
|
||||
location.href = '/?redirect=/admin/';
|
||||
return;
|
||||
}
|
||||
document.getElementById('auth-check').style.display = 'none';
|
||||
document.getElementById('app').style.display = '';
|
||||
document.getElementById('user-name').textContent = me.data.username;
|
||||
document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase();
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||
document.getElementById('logout-btn').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
await Nova.api('auth', 'logout', { method: 'POST' });
|
||||
location.href = '/';
|
||||
});
|
||||
|
||||
// ── Page definitions ───────────────────────────────────────────────────────
|
||||
const pages = {
|
||||
dashboard,
|
||||
'server-status': serverStatus,
|
||||
accounts,
|
||||
resellers,
|
||||
packages,
|
||||
'create-account': createAccount,
|
||||
'dns-zones': dnsZones,
|
||||
nameservers,
|
||||
'web-server': webServer,
|
||||
'php-manager': phpManager,
|
||||
'mysql-manager': mysqlManager,
|
||||
'mail-server': mailServer,
|
||||
'ftp-server': ftpServer,
|
||||
'ssl-manager': sslManager,
|
||||
firewall,
|
||||
'audit-log': auditLog,
|
||||
updates,
|
||||
backups,
|
||||
settings,
|
||||
};
|
||||
|
||||
Nova.initNav(pages);
|
||||
await Nova.loadPage('dashboard', pages);
|
||||
checkUpdates();
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
async function dashboard() {
|
||||
const [stats, version] = await Promise.all([
|
||||
Nova.api('system', 'stats'),
|
||||
Nova.api('system', 'version'),
|
||||
]);
|
||||
const s = stats?.data || {};
|
||||
const v = version?.data || {};
|
||||
|
||||
document.getElementById('server-ip').textContent = '';
|
||||
|
||||
return `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CPU Usage</div>
|
||||
<div class="stat-value ${s.cpu?.pct > 80 ? 'stat-red' : 'stat-green'}">${s.cpu?.pct ?? 0}%</div>
|
||||
<div class="stat-sub">Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}</div>
|
||||
<div class="mt-1">${Nova.progressBar(s.cpu?.pct || 0)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Memory</div>
|
||||
<div class="stat-value ${s.ram?.pct > 80 ? 'stat-red' : 'stat-blue'}">${s.ram?.pct ?? 0}%</div>
|
||||
<div class="stat-sub">${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}</div>
|
||||
<div class="mt-1">${Nova.progressBar(s.ram?.pct || 0)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Disk</div>
|
||||
<div class="stat-value ${s.disk?.pct > 85 ? 'stat-red' : 'stat-yellow'}">${s.disk?.pct ?? 0}%</div>
|
||||
<div class="stat-sub">${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used</div>
|
||||
<div class="mt-1">${Nova.progressBar(s.disk?.pct || 0)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value stat-green" style="font-size:1rem;padding-top:.4rem">${s.uptime || '—'}</div>
|
||||
<div class="stat-sub">PHP ${v.php_version || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2 gap-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Services</span></div>
|
||||
<div class="card-body">
|
||||
<table><tbody>
|
||||
${Object.entries(s.services || {}).map(([svc, status]) => `
|
||||
<tr>
|
||||
<td>${Nova.serviceDot(status)} ${svc}</td>
|
||||
<td>${Nova.badge(status, status === 'active' ? 'green' : 'red')}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','restart')">Restart</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','stop')">Stop</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">NovaCPX Version</span></div>
|
||||
<div class="card-body">
|
||||
<table><tbody>
|
||||
<tr><td class="text-muted">Installed</td><td><strong>${v.installed_version || '—'}</strong></td></tr>
|
||||
<tr><td class="text-muted">Branch</td><td><code>${v.git_branch || 'main'}</code></td></tr>
|
||||
<tr><td class="text-muted">Commit</td><td><code>${v.git_commit || '—'}</code>${v.git_dirty ? ' <span class="badge badge-yellow">dirty</span>' : ''}</td></tr>
|
||||
<tr><td class="text-muted">PHP</td><td>${v.php_version || '—'}</td></tr>
|
||||
<tr><td class="text-muted">OS</td><td>${v.os || '—'}</td></tr>
|
||||
</tbody></table>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" onclick="adminPage('updates')">Check for Updates</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Server Status ──────────────────────────────────────────────────────────
|
||||
async function serverStatus() {
|
||||
const res = await Nova.api('system', 'stats');
|
||||
const s = res?.data || {};
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Real-Time Server Status</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="adminPage('server-status')">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-3">
|
||||
<div><p class="text-muted text-sm mb-1">CPU</p><h2>${s.cpu?.pct}%</h2>${Nova.progressBar(s.cpu?.pct||0)}</div>
|
||||
<div><p class="text-muted text-sm mb-1">RAM</p><h2>${s.ram?.pct}%</h2>${Nova.progressBar(s.ram?.pct||0)}</div>
|
||||
<div><p class="text-muted text-sm mb-1">Disk</p><h2>${s.disk?.pct}%</h2>${Nova.progressBar(s.disk?.pct||0)}</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<p class="text-muted text-sm mb-1">Load Average</p>
|
||||
<p>${(s.cpu?.load||[]).join(' / ')}</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<p class="text-muted text-sm mb-1">Uptime</p>
|
||||
<p>${s.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────────────────────
|
||||
async function updates() {
|
||||
const [ver, check] = await Promise.all([
|
||||
Nova.api('system', 'version'),
|
||||
Nova.api('system', 'check-update'),
|
||||
]);
|
||||
const v = ver?.data || {};
|
||||
const upd = check?.data || {};
|
||||
const count = upd.updates_available || 0;
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">NovaCPX Updates</span>
|
||||
${count > 0 ? Nova.badge(count + ' update' + (count > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2 mb-3">
|
||||
<div><p class="text-muted text-sm">Installed Version</p><p class="font-bold">${v.installed_version}</p></div>
|
||||
<div><p class="text-muted text-sm">Git Commit</p><code>${v.git_commit || '—'}</code></div>
|
||||
<div><p class="text-muted text-sm">Branch</p><code>${v.git_branch || 'main'}</code></div>
|
||||
<div><p class="text-muted text-sm">Dirty Working Tree</p><p>${v.git_dirty ? Nova.badge('Yes','yellow') : Nova.badge('No','green')}</p></div>
|
||||
</div>
|
||||
|
||||
${count > 0 ? `
|
||||
<div class="card mb-2" style="background:var(--bg3)">
|
||||
<div class="card-header"><span class="card-title">Pending Commits</span></div>
|
||||
<div class="card-body terminal">
|
||||
${upd.commits?.map(c => `<div>${c}</div>`).join('') || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="applyUpdate()">Apply Update</button>
|
||||
` : `<p class="text-muted">NovaCPX is up to date.</p>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
async function auditLog() {
|
||||
const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
|
||||
const rows = res?.data || [];
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Audit Log</span></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr>
|
||||
<td class="text-muted text-sm">${Nova.relTime(r.created_at)}</td>
|
||||
<td>${r.username || '—'}</td>
|
||||
<td><code>${r.action}</code></td>
|
||||
<td>${r.resource || '—'}</td>
|
||||
<td class="text-muted text-sm">${r.ip_address || '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── PHP Manager ────────────────────────────────────────────────────────────
|
||||
async function phpManager() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">PHP Version Manager</span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-2">Manage installed PHP versions and global extensions.</p>
|
||||
<div class="grid-4">
|
||||
${['7.4','8.1','8.2','8.3'].map(v => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">PHP ${v}</div>
|
||||
<div class="stat-value" style="font-size:1rem">${Nova.badge('Active','green')}</div>
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm" onclick="phpAction('${v}','fpm-restart')">Restart FPM</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h4 class="mb-1">Global PHP Extensions</h4>
|
||||
<p class="text-muted text-sm">Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
async function settings() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Panel Settings</span></div>
|
||||
<div class="card-body">
|
||||
<form id="settings-form">
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Panel Name</label><input type="text" name="panel_name" value="NovaCPX"></div>
|
||||
<div class="form-group"><label>Default PHP Version</label>
|
||||
<select name="default_php">
|
||||
${['7.4','8.1','8.2','8.3'].map(v => `<option value="${v}" ${v==='8.3'?'selected':''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Primary Nameserver</label><input type="text" name="default_nameserver1" value="ns1.example.com"></div>
|
||||
<div class="form-group"><label>Secondary Nameserver</label><input type="text" name="default_nameserver2" value="ns2.example.com"></div>
|
||||
<div class="form-group"><label>Update Channel</label>
|
||||
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Git Remote</label><input type="url" name="git_remote" value="https://github.com/myronblair/novacpx.git"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Stub pages ─────────────────────────────────────────────────────────────
|
||||
function stubPage(title, desc) {
|
||||
return `<div class="card"><div class="card-header"><span class="card-title">${title}</span></div>
|
||||
<div class="card-body"><p class="text-muted">${desc}</p>
|
||||
<div class="mt-2">${Nova.badge('Coming Soon','yellow')}</div></div></div>`;
|
||||
}
|
||||
function accounts() { return stubPage('All Accounts', 'View and manage all hosting accounts on this server.'); }
|
||||
function resellers() { return stubPage('Resellers', 'Create and manage reseller accounts with custom packages and resource limits.'); }
|
||||
function packages() { return stubPage('Packages', 'Define hosting packages with disk, bandwidth, email, FTP, and database limits.'); }
|
||||
function createAccount() { return stubPage('Create Account', 'Create a new hosting account and assign it a package.'); }
|
||||
function dnsZones() { return stubPage('DNS Zones', 'View, add, and edit all DNS zones on this nameserver.'); }
|
||||
function nameservers() { return stubPage('Nameservers', 'Configure primary and secondary nameservers for all hosted domains.'); }
|
||||
function webServer() { return stubPage('Web Server', 'Manage Apache2 / nginx virtual hosts, modules, and configuration.'); }
|
||||
function mysqlManager() { return stubPage('MySQL / PostgreSQL', 'Create databases, users, and manage remote access.'); }
|
||||
function mailServer() { return stubPage('Mail Server', 'Manage Postfix/Dovecot configuration, spam filters, and mail queues.'); }
|
||||
function ftpServer() { return stubPage('FTP Server', 'Configure ProFTPD, manage FTP accounts and access rules.'); }
|
||||
function sslManager() { return stubPage('SSL Manager', 'Issue, install, and auto-renew Let\'s Encrypt SSL certificates for all domains.'); }
|
||||
function firewall() { return stubPage('Firewall / Fail2Ban', 'Manage UFW rules and review Fail2Ban bans.'); }
|
||||
function backups() { return stubPage('Backups', 'Configure automated backups, restore accounts, and manage backup storage.'); }
|
||||
|
||||
// ── Global action helpers ──────────────────────────────────────────────────
|
||||
window.adminPage = (page) => Nova.loadPage(page, pages);
|
||||
window.applyUpdate = async () => {
|
||||
Nova.confirm('Apply all pending updates? The panel may restart.', async () => {
|
||||
Nova.toast('Applying update…', 'info', 8000);
|
||||
const res = await Nova.api('system', 'apply-update', { method: 'POST' });
|
||||
if (res?.data?.updated) {
|
||||
Nova.toast(`Updated to ${res.data.to_commit}`, 'success');
|
||||
Nova.loadPage('updates', pages);
|
||||
} else {
|
||||
Nova.toast(res?.data?.pull_output || 'Already up to date', 'info');
|
||||
}
|
||||
});
|
||||
};
|
||||
window.adminServiceAction = async (svc, cmd) => {
|
||||
const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } });
|
||||
Nova.toast(`${svc}: ${cmd} → ${res?.success ? 'OK' : res?.message}`, res?.success ? 'success' : 'error');
|
||||
};
|
||||
window.phpAction = async (ver, cmd) => {
|
||||
const svc = `php${ver}-fpm`;
|
||||
await window.adminServiceAction(svc, 'restart');
|
||||
};
|
||||
|
||||
// ── Check for updates badge ────────────────────────────────────────────────
|
||||
async function checkUpdates() {
|
||||
const res = await Nova.api('system', 'check-update');
|
||||
const n = res?.data?.updates_available || 0;
|
||||
const badge = document.getElementById('update-badge');
|
||||
if (badge && n > 0) { badge.textContent = n; badge.style.display = ''; }
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* NovaCPX — Shared JS utilities
|
||||
*/
|
||||
|
||||
window.Nova = (() => {
|
||||
// ── API ───────────────────────────────────────────────────────────────────
|
||||
async function api(endpoint, action, opts = {}) {
|
||||
const { method = 'GET', body, params } = opts;
|
||||
let url = `/api/${endpoint}/${action}`;
|
||||
if (params) url += '?' + new URLSearchParams(params);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────
|
||||
let toastEl = null;
|
||||
function toast(msg, type = 'info', duration = 3500) {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement('div');
|
||||
toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = `alert alert-${type}`;
|
||||
el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)';
|
||||
el.textContent = msg;
|
||||
el.addEventListener('click', () => el.remove());
|
||||
toastEl.appendChild(el);
|
||||
setTimeout(() => el.remove(), duration);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
function modal(title, bodyHtml, footerHtml = '') {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'modal-overlay open';
|
||||
ov.innerHTML = `<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">${title}</span>
|
||||
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${bodyHtml}</div>
|
||||
${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}
|
||||
</div>`;
|
||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||
document.body.appendChild(ov);
|
||||
return ov;
|
||||
}
|
||||
|
||||
// ── Confirm dialog ────────────────────────────────────────────────────────
|
||||
function confirm(msg, onYes, danger = false) {
|
||||
const ov = modal('Confirm', `<p>${msg}</p>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-${danger ? 'red' : 'primary'}" id="confirm-yes">Confirm</button>`
|
||||
);
|
||||
ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); };
|
||||
}
|
||||
|
||||
// ── Sidebar navigation ────────────────────────────────────────────────────
|
||||
function initNav(pages) {
|
||||
document.querySelectorAll('[data-page]').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const page = link.dataset.page;
|
||||
document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl) titleEl.textContent = link.textContent.trim();
|
||||
loadPage(page, pages);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadPage(page, pages) {
|
||||
const content = document.getElementById('page-content');
|
||||
if (!content) return;
|
||||
const fn = pages[page];
|
||||
if (fn) {
|
||||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||
Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
|
||||
} else {
|
||||
content.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Page "${page}" coming soon.</p></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar helper ───────────────────────────────────────────────────
|
||||
function progressBar(pct) {
|
||||
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
|
||||
return `<div class="progress"><div class="progress-bar ${color}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
// ── Format helpers ────────────────────────────────────────────────────────
|
||||
function bytes(n) {
|
||||
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
|
||||
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
||||
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return n + ' B';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
const diff = (Date.now() - new Date(dateStr)) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
function badge(text, type = 'blue') {
|
||||
return `<span class="badge badge-${type}">${text}</span>`;
|
||||
}
|
||||
function serviceDot(status) {
|
||||
const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
|
||||
return `<span class="service-dot ${cls}"></span>`;
|
||||
}
|
||||
|
||||
// Inject global CSS animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}';
|
||||
document.head.appendChild(style);
|
||||
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot };
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
// NovaCPX entry point — redirect based on role or show login
|
||||
session_start();
|
||||
$redirect = $_GET['redirect'] ?? '';
|
||||
$safeRedirect = preg_match('#^/(user|reseller|admin)#', $redirect) ? $redirect : '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX — Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
|
||||
<div class="login-wrap">
|
||||
<div class="login-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Sign In</h1>
|
||||
<p class="login-sub">Linux Web Hosting Control Panel</p>
|
||||
|
||||
<div id="login-error" class="alert alert-error" style="display:none"></div>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username or Email</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
<button type="button" class="eye-toggle" data-target="password">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="login-btn">
|
||||
<span class="btn-text">Sign In</span>
|
||||
<span class="btn-spinner" style="display:none">Signing in…</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
NovaCPX v<span id="panel-version">1.0.0</span> |
|
||||
<a href="/api/system/version" target="_blank">System Info</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const REDIRECT = <?= json_encode($safeRedirect) ?>;
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-btn');
|
||||
const err = document.getElementById('login-error');
|
||||
btn.querySelector('.btn-text').style.display = 'none';
|
||||
btn.querySelector('.btn-spinner').style.display = '';
|
||||
btn.disabled = true;
|
||||
err.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message || 'Login failed');
|
||||
|
||||
const role = data.data.user.role;
|
||||
const dest = REDIRECT || (role === 'admin' ? '/admin/' : role === 'reseller' ? '/reseller/' : '/user/');
|
||||
location.href = dest;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.style.display = '';
|
||||
btn.querySelector('.btn-text').style.display = '';
|
||||
btn.querySelector('.btn-spinner').style.display = 'none';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Password toggle
|
||||
document.querySelectorAll('.eye-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const inp = document.getElementById(btn.dataset.target);
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch version
|
||||
fetch('/api/auth/me', {credentials:'include'}).then(r => r.json()).then(d => {
|
||||
if (d.success) {
|
||||
const role = d.data.role;
|
||||
location.href = role === 'admin' ? '/admin/' : role === 'reseller' ? '/reseller/' : '/user/';
|
||||
}
|
||||
});
|
||||
fetch('/api/system/version', {credentials:'include'})
|
||||
.then(r=>r.json()).then(d=>{ if(d.data?.installed_version) document.getElementById('panel-version').textContent=d.data.installed_version; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
// NovaCPX User Panel — End-user hosting dashboard
|
||||
// Design: Horizontal feature cards with usage rings, NOT cPanel icon grid
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX — My Hosting</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
<style>
|
||||
/* ── User panel specific ─────────────────────────────── */
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
transition: border-color .15s, transform .1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feature-card:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.feature-icon {
|
||||
width: 44px; height: 44px; flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.fi-purple { background: rgba(99,102,241,.15); color: var(--primary); }
|
||||
.fi-sky { background: rgba(14,165,233,.15); color: var(--sky); }
|
||||
.fi-green { background: rgba(16,185,129,.15); color: var(--green); }
|
||||
.fi-yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||||
.fi-red { background: rgba(239,68,68,.15); color: var(--red); }
|
||||
.fi-pink { background: rgba(236,72,153,.15); color: #f472b6; }
|
||||
.fi-teal { background: rgba(20,184,166,.15); color: #2dd4bf; }
|
||||
.fi-orange { background: rgba(249,115,22,.15); color: #fb923c; }
|
||||
.feature-icon svg { width: 22px; height: 22px; }
|
||||
.feature-info { flex: 1; min-width: 0; }
|
||||
.feature-name { font-weight: 600; font-size: .9rem; margin-bottom: .2rem; }
|
||||
.feature-desc { font-size: .78rem; color: var(--text-muted); line-height: 1.4; }
|
||||
.feature-meta { font-size: .75rem; color: var(--primary); margin-top: .3rem; }
|
||||
|
||||
/* Usage ring */
|
||||
.usage-rings {
|
||||
display: flex; gap: 2rem; align-items: center;
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.ring-item { text-align: center; }
|
||||
.ring-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-top: .5rem; }
|
||||
.ring-val { font-size: .85rem; font-weight: 600; margin-top: .15rem; }
|
||||
svg.ring { transform: rotate(-90deg); }
|
||||
svg.ring circle { transition: stroke-dashoffset .5s; }
|
||||
|
||||
/* Breadcrumb / section tabs */
|
||||
.section-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.section-header h2 { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#ulg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#ulg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="ulg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="ulg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong></span>
|
||||
</div>
|
||||
<nav>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">My Account</div>
|
||||
<a href="#" class="sidebar-link active" data-page="home">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> Home
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="domains">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> Domains
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="files">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> File Manager
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Email</div>
|
||||
<a href="#" class="sidebar-link" data-page="email-accounts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Email Accounts
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="forwarders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> Forwarders
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="autoresponders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Autoresponders
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Databases</div>
|
||||
<a href="#" class="sidebar-link" data-page="mysql">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> MySQL
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="postgres">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> PostgreSQL
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Advanced</div>
|
||||
<a href="#" class="sidebar-link" data-page="ftp">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> FTP Accounts
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="dns">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> DNS Editor
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="ssl">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> SSL / TLS
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="php-config">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> PHP Config
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="cron">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Cron Jobs
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="backups">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Backups
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="logs">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></svg> Error Logs
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">U</div>
|
||||
<div><div class="user-name" id="user-name">User</div><div class="user-role" id="user-domain">example.com</div></div>
|
||||
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title" id="page-title">My Hosting</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="account-domain" class="text-muted text-sm"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh">
|
||||
<div style="text-align:center;color:var(--text-muted)">Loading…</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/nova.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const me = await Nova.api('auth', 'me');
|
||||
if (!me?.success) { location.href = '/?redirect=/user/'; return; }
|
||||
|
||||
document.getElementById('auth-check').style.display = 'none';
|
||||
document.getElementById('app').style.display = '';
|
||||
document.getElementById('user-name').textContent = me.data.username;
|
||||
document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase();
|
||||
|
||||
document.getElementById('logout-btn').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
await Nova.api('auth', 'logout', { method: 'POST' });
|
||||
location.href = '/';
|
||||
});
|
||||
|
||||
function ring(pct, color, r = 28) {
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ * (1 - pct / 100);
|
||||
return `<svg class="ring" width="${r*2+8}" height="${r*2+8}" viewBox="0 0 ${r*2+8} ${r*2+8}">
|
||||
<circle cx="${r+4}" cy="${r+4}" r="${r}" fill="none" stroke="var(--border)" stroke-width="5"/>
|
||||
<circle cx="${r+4}" cy="${r+4}" r="${r}" fill="none" stroke="${color}" stroke-width="5"
|
||||
stroke-dasharray="${circ}" stroke-dashoffset="${offset}" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const homePage = () => `
|
||||
<div class="usage-rings">
|
||||
<div>
|
||||
<h2 style="font-size:1.1rem;font-weight:700">My Hosting</h2>
|
||||
<p class="text-muted text-sm">example.com · Active</p>
|
||||
</div>
|
||||
<div style="margin-left:auto;display:flex;gap:2rem">
|
||||
<div class="ring-item">${ring(45,'#6366f1')}<div class="ring-label">Disk</div><div class="ring-val">2.3 GB / 5 GB</div></div>
|
||||
<div class="ring-item">${ring(22,'#0ea5e9')}<div class="ring-label">Bandwidth</div><div class="ring-val">2.2 GB / 10 GB</div></div>
|
||||
<div class="ring-item">${ring(60,'#10b981')}<div class="ring-label">Email</div><div class="ring-val">6 / 10</div></div>
|
||||
<div class="ring-item">${ring(30,'#f59e0b')}<div class="ring-label">Databases</div><div class="ring-val">3 / 10</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header"><h2>Quick Access</h2></div>
|
||||
<div class="feature-grid">
|
||||
${[
|
||||
{ page:'files', icon:'folder', color:'fi-yellow', name:'File Manager', desc:'Upload, edit, and manage your website files and directories.' },
|
||||
{ page:'email-accounts',icon:'mail', color:'fi-sky', name:'Email Accounts', desc:'Create and manage mailboxes for your domains.' },
|
||||
{ page:'mysql', icon:'db', color:'fi-green', name:'MySQL Databases', desc:'Create databases and users for your PHP applications.' },
|
||||
{ page:'postgres', icon:'db', color:'fi-teal', name:'PostgreSQL', desc:'Manage PostgreSQL databases and connections.' },
|
||||
{ page:'domains', icon:'globe', color:'fi-purple', name:'Domains', desc:'Add subdomains, addon domains, and domain aliases.' },
|
||||
{ page:'dns', icon:'dns', color:'fi-orange', name:'DNS Editor', desc:'Manage A, CNAME, MX, TXT, and SRV records.' },
|
||||
{ page:'ssl', icon:'lock', color:'fi-green', name:'SSL / TLS', desc:'Issue free Let\'s Encrypt certificates for your domains.' },
|
||||
{ page:'ftp', icon:'ftp', color:'fi-pink', name:'FTP Accounts', desc:'Create FTP users with directory access controls.' },
|
||||
{ page:'php-config', icon:'code', color:'fi-purple', name:'PHP Config', desc:'Switch PHP version and configure php.ini settings per domain.' },
|
||||
{ page:'cron', icon:'clock', color:'fi-yellow', name:'Cron Jobs', desc:'Schedule automated tasks on any interval.' },
|
||||
{ page:'forwarders', icon:'forward', color:'fi-sky', name:'Email Forwarders', desc:'Forward emails from one address to another.' },
|
||||
{ page:'backups', icon:'backup', color:'fi-red', name:'Backups', desc:'Create full or partial backups and restore files.' },
|
||||
].map(f => `
|
||||
<div class="feature-card" onclick="loadUserPage('${f.page}')">
|
||||
<div class="feature-icon ${f.color}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
${svgPath(f.icon)}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-info">
|
||||
<div class="feature-name">${f.name}</div>
|
||||
<div class="feature-desc">${f.desc}</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
|
||||
function svgPath(icon) {
|
||||
const p = {
|
||||
folder:'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
mail:'<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
|
||||
db:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
|
||||
globe:'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
dns:'<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>',
|
||||
lock:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
ftp:'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
code:'<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
clock:'<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
forward:'<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>',
|
||||
backup:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
};
|
||||
return p[icon] || '';
|
||||
}
|
||||
|
||||
const pages = { home: homePage };
|
||||
Nova.initNav(pages);
|
||||
document.getElementById('page-content').innerHTML = homePage();
|
||||
|
||||
window.loadUserPage = (page) => Nova.loadPage(page, pages);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user