feat: add Rust backend — workspace with section executables

Cargo workspace with four crates:
- nixpanel-common: shared SectionRequest/SectionResponse types
- nixpanel-api: main Axum HTTP server (JWT auth, DB, routing)
- nixpanel-services: section binary for systemctl start/stop/restart
- nixpanel-sysinfo: section binary for CPU/RAM/disk/OS/version reads

nixpanel-api features:
- POST /api/auth/login — Argon2id verification, HS256 JWT
- GET  /api/dashboard  — real account counts + sysinfo + versions
- GET  /api/services   — live systemctl status via section binary
- POST /api/services/:name/:action — start/stop/restart
- GET  /api/accounts   — list from MariaDB
- POST /api/accounts   — create account + system user

Section binaries are UPX-compressed after build.
DB schema auto-migrated on first start; admin user seeded from
/opt/nixpanel/.admin_user + .admin_pass files.

Frontend changes:
- nixserver/src/api.ts + App.tsx wired to real API (JWT stored in
  localStorage, real data in dashboard/services/accounts)
- nixclient/src/api.ts added
- Both vite configs get /api proxy to localhost:4000 for dev
- install.sh updated to copy + compress all section binaries
This commit is contained in:
NixReaper
2026-04-25 16:41:57 -05:00
parent a257b5571c
commit 5247c77132
26 changed files with 1899 additions and 331 deletions
+15
View File
@@ -0,0 +1,15 @@
[workspace]
resolver = "2"
members = [
"nixpanel-common",
"nixpanel-api",
"nixpanel-services",
"nixpanel-sysinfo",
]
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
panic = "abort"
+28 -4
View File
@@ -244,11 +244,35 @@ step "Building nixpanel (Rust)"
if ! step_done "build_panel"; then
if [[ -f "$SRC_DIR/Cargo.toml" ]]; then
cd "$SRC_DIR"
$CARGO build --release --quiet
[[ -f "target/release/nixpanel" ]] && \
cp target/release/nixpanel "$BIN_DIR/nixpanel" && chmod 755 "$BIN_DIR/nixpanel"
info "Compiling nixpanel workspace (this may take a few minutes on first build)…"
$CARGO build --release
# Install UPX if available for binary compression
if ! command -v upx &>/dev/null; then
apt-get install -y -qq upx-ucl 2>/dev/null || true
fi
# Main API binary
if [[ -f "target/release/nixpanel" ]]; then
cp target/release/nixpanel "$BIN_DIR/nixpanel"
chmod 755 "$BIN_DIR/nixpanel"
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/nixpanel" 2>/dev/null || true
success "nixpanel binary installed"
fi
# Section binaries — each section is a separate compressed executable
mkdir -p "$BIN_DIR"
for section_bin in target/release/nixpanel-*; do
[[ -f "$section_bin" ]] || continue
bin_name=$(basename "$section_bin")
cp "$section_bin" "$BIN_DIR/$bin_name"
chmod 755 "$BIN_DIR/$bin_name"
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/$bin_name" 2>/dev/null || true
success "Section binary installed: $bin_name"
done
mark_done "build_panel"
success "Panel binary built"
success "Panel binaries built and compressed"
else
warn "Panel Rust backend not yet present in repo — skipping build"
warn "This is expected during early development. Re-run installer when source is ready."
+70
View File
@@ -0,0 +1,70 @@
/* NixClient API client — all calls go to /api/ (proxied to Rust backend) */
const BASE = '/api'
export function getToken(): string | null {
return localStorage.getItem('nixclient_token')
}
export function setToken(t: string) {
localStorage.setItem('nixclient_token', t)
}
export function clearToken() {
localStorage.removeItem('nixclient_token')
}
export function isLoggedIn(): boolean {
return !!getToken()
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
})
if (res.status === 401) {
clearToken()
window.location.reload()
throw new Error('Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json()
}
export interface LoginResult {
token: string
user: { id: number; username: string; role: string }
}
export async function login(username: string, password: string): Promise<LoginResult> {
const result = await apiFetch<LoginResult>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
setToken(result.token)
return result
}
export interface AccountInfo {
username: string
domain: string
email: string
package_name: string
disk_quota_mb: number
bandwidth_mb: number
status: string
}
export async function getMyAccount(): Promise<AccountInfo | null> {
try {
// Returns the first account matching the logged-in user's username
const accounts = await apiFetch<AccountInfo[]>('/accounts')
return accounts[0] ?? null
} catch {
return null
}
}
+6
View File
@@ -11,5 +11,11 @@ export default defineConfig({
server: {
port: 3001,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "nixpanel"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nixpanel"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors"] }
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-rustls", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
anyhow = "1"
+127
View File
@@ -0,0 +1,127 @@
-- Users (panel logins: admin + per-account users)
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','user') NOT NULL DEFAULT 'user',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
UNIQUE KEY uq_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Hosting accounts
CREATE TABLE IF NOT EXISTS accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED,
username VARCHAR(64) NOT NULL,
domain VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
package_name VARCHAR(64) NOT NULL DEFAULT 'Default',
disk_quota_mb INT NOT NULL DEFAULT 10240,
bandwidth_mb INT NOT NULL DEFAULT 0,
status ENUM('active','suspended','terminated') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
UNIQUE KEY uq_domain (domain),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Domains / DNS zones
CREATE TABLE IF NOT EXISTS domains (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
name VARCHAR(255) NOT NULL,
type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_name (name),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- DNS records
CREATE TABLE IF NOT EXISTS dns_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(10) NOT NULL, -- A, AAAA, CNAME, MX, TXT, NS, SRV
name VARCHAR(255) NOT NULL,
content VARCHAR(512) NOT NULL,
ttl INT NOT NULL DEFAULT 3600,
priority INT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MySQL/MariaDB databases owned by accounts
CREATE TABLE IF NOT EXISTS account_databases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
db_name VARCHAR(64) NOT NULL,
db_user VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_db_name (db_name),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Email accounts
CREATE TABLE IF NOT EXISTS email_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
address VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
quota_mb INT NOT NULL DEFAULT 1024,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_address (address),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- FTP accounts
CREATE TABLE IF NOT EXISTS ftp_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL,
home_dir VARCHAR(512) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SSL certificates
CREATE TABLE IF NOT EXISTS ssl_certificates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
domain VARCHAR(255) NOT NULL,
issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt',
cert_path VARCHAR(512),
key_path VARCHAR(512),
expires_at DATETIME,
auto_renew TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_domain (domain),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Backup jobs
CREATE TABLE IF NOT EXISTS backups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
type ENUM('full','incremental') NOT NULL DEFAULT 'full',
status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending',
size_bytes BIGINT,
path VARCHAR(512),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
PRIMARY KEY (id),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+74
View File
@@ -0,0 +1,74 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
RequestPartsExt,
};
use axum_extra::TypedHeader;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::state::AppState;
/* ── JWT claims ──────────────────────────────────────────────────────── */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // username
pub user_id: i64,
pub role: String, // "admin" | "user"
pub exp: i64, // unix timestamp
pub iat: i64,
}
impl Claims {
pub fn new(user_id: i64, username: &str, role: &str, secret: &str) -> anyhow::Result<String> {
let now = Utc::now();
let claims = Claims {
sub: username.to_string(),
user_id,
role: role.to_string(),
exp: (now + Duration::hours(8)).timestamp(),
iat: now.timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)?;
Ok(data.claims)
}
}
/* ── Extractor: pulls Claims from Authorization: Bearer <token> ──────── */
#[async_trait]
impl FromRequestParts<AppState> for Claims {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers;
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
Claims::verify(auth, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized)
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub port: u16,
pub jwt_secret: String,
pub database_url: String,
pub install_dir: String,
pub bin_dir: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse()?,
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()),
database_url: env::var("DATABASE_URL")?,
install_dir: env::var("INSTALL_DIR").unwrap_or_else(|_| "/opt/nixpanel".into()),
bin_dir: env::var("INSTALL_DIR")
.map(|d| format!("{}/bin", d))
.unwrap_or_else(|_| "/opt/nixpanel/bin".into()),
})
}
}
+48
View File
@@ -0,0 +1,48 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Unauthorized")]
Unauthorized,
#[error("Not found")]
NotFound,
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Internal(_) => {
tracing::error!("{}", self);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Internal(anyhow::anyhow!(e))
}
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
AppError::Unauthorized
}
}
+160
View File
@@ -0,0 +1,160 @@
mod auth;
mod config;
mod error;
mod routes;
mod section;
mod state;
use std::sync::Arc;
use std::net::SocketAddr;
use axum::{
routing::{get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
use config::Config;
use state::AppState;
/* ── DB schema bootstrap ────────────────────────────────────────────── */
async fn run_migrations(pool: &sqlx::MySqlPool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS _schema_version (
version INT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
).execute(pool).await?;
let current: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(version), 0) FROM _schema_version"
).fetch_one(pool).await.unwrap_or(0);
if current < 1 {
info!("Running migration 1: initial schema");
for stmt in include_str!("../migrations/0001_initial.sql")
.split(";\n")
.map(str::trim)
.filter(|s| !s.is_empty())
{
sqlx::query(stmt).execute(pool).await?;
}
sqlx::query("INSERT INTO _schema_version (version) VALUES (1)")
.execute(pool).await?;
}
Ok(())
}
/* ── Admin user seeding ─────────────────────────────────────────────── */
async fn seed_admin(pool: &sqlx::MySqlPool, install_dir: &str) -> anyhow::Result<()> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetch_one(pool).await.unwrap_or(0);
if count > 0 {
return Ok(()); // already seeded
}
let username = std::fs::read_to_string(format!("{}/.admin_user", install_dir))
.unwrap_or_else(|_| "admin".into())
.trim()
.to_string();
let password = std::fs::read_to_string(format!("{}/.admin_pass", install_dir))
.unwrap_or_else(|_| "changeme".into())
.trim()
.to_string();
let email = format!("{}@localhost", username);
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("argon2: {}", e))?
.to_string();
sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'admin')"
)
.bind(&username)
.bind(&email)
.bind(&hash)
.execute(pool)
.await?;
info!("Seeded admin user: {}", username);
Ok(())
}
/* ── Router ─────────────────────────────────────────────────────────── */
fn build_router(state: AppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
// Auth (public)
.route("/api/auth/login", post(routes::auth::login))
// Dashboard
.route("/api/dashboard", get(routes::dashboard::get_dashboard))
// Services
.route("/api/services", get(routes::services::list_services))
.route("/api/services/:name/:action", post(routes::services::service_action))
// Accounts
.route("/api/accounts", get(routes::accounts::list_accounts)
.post(routes::accounts::create_account))
.with_state(state)
.layer(cors)
}
/* ── Entry point ────────────────────────────────────────────────────── */
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load .env if present (production uses EnvironmentFile= in systemd)
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "nixpanel=info,tower_http=warn".into())
)
.init();
let config = Config::from_env()?;
info!("NixPanel API starting on port {}", config.port);
// Connect to MariaDB
let pool = sqlx::MySqlPool::connect(&config.database_url).await?;
// Migrations
run_migrations(&pool).await?;
// Seed admin on first run
seed_admin(&pool, &config.install_dir).await
.unwrap_or_else(|e| tracing::warn!("Admin seed skipped: {}", e));
let state = AppState {
db: pool,
config: Arc::new(config.clone()),
};
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Listening on {}", addr);
axum::serve(listener, build_router(state)).await?;
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use axum::{extract::State, Json};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Serialize, sqlx::FromRow)]
pub struct Account {
pub id: i64,
pub username: String,
pub domain: String,
pub email: String,
pub package_name: String,
pub disk_quota_mb: i64,
pub bandwidth_mb: i64,
pub status: String,
pub created_at: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct CreateAccountRequest {
pub username: String,
pub domain: String,
pub email: String,
pub password: String,
pub package_name: Option<String>,
pub disk_quota_mb: Option<i64>,
pub bandwidth_mb: Option<i64>,
}
pub async fn list_accounts(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<Vec<Account>>, AppError> {
let accounts = sqlx::query_as!(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await?;
Ok(Json(accounts))
}
pub async fn create_account(
claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateAccountRequest>,
) -> Result<Json<Account>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
// Validate
if body.username.is_empty() || body.domain.is_empty() {
return Err(AppError::BadRequest("username and domain are required".into()));
}
// Hash password
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(body.password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.to_string();
// Create system user (best-effort — panel may not be running as root in dev)
let _ = std::process::Command::new("useradd")
.args(&[
"-m",
"-s", "/bin/bash",
"-d", &format!("/home/{}", body.username),
&body.username,
])
.output();
// Create Apache vhost directory
let vhost_dir = format!("/home/{}/public_html", body.username);
let _ = std::fs::create_dir_all(&vhost_dir);
let package_name = body.package_name.unwrap_or_else(|| "Default".into());
let disk_quota_mb = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb = body.bandwidth_mb.unwrap_or(0);
let id = sqlx::query!(
"INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')",
body.username, body.domain, body.email, hash,
package_name, disk_quota_mb, bandwidth_mb
)
.execute(&state.db)
.await?
.last_insert_id() as i64;
// Also create a panel user so they can log into NixClient
let _ = sqlx::query!(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')
ON DUPLICATE KEY UPDATE email = VALUES(email)",
body.username, body.email, hash
)
.execute(&state.db)
.await;
let account = sqlx::query_as!(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts WHERE id = ?",
id
)
.fetch_one(&state.db)
.await?;
Ok(Json(account))
}
+58
View File
@@ -0,0 +1,58 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String,
pub user: UserInfo,
}
#[derive(Serialize)]
pub struct UserInfo {
pub id: i64,
pub username: String,
pub role: String,
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
// Look up user
let row = sqlx::query!(
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
body.username
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Unauthorized)?;
// Verify password
let parsed = PasswordHash::new(&row.password_hash)
.map_err(|_| AppError::Unauthorized)?;
Argon2::default()
.verify_password(body.password.as_bytes(), &parsed)
.map_err(|_| AppError::Unauthorized)?;
// Issue JWT
let token = Claims::new(row.id, &row.username, &row.role, &state.config.jwt_secret)
.map_err(|e| AppError::Internal(e))?;
Ok(Json(LoginResponse {
token,
user: UserInfo {
id: row.id,
username: row.username,
role: row.role,
},
}))
}
+54
View File
@@ -0,0 +1,54 @@
use axum::{extract::State, Json};
use serde::Serialize;
use crate::{auth::Claims, error::AppError, section, state::AppState};
#[derive(Serialize)]
pub struct DashboardStats {
pub accounts: i64,
pub domains: i64,
pub databases: i64,
pub email_accounts: i64,
}
#[derive(Serialize)]
pub struct DashboardResponse {
pub stats: DashboardStats,
pub sysinfo: serde_json::Value,
pub versions: serde_json::Value,
}
pub async fn get_dashboard(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<DashboardResponse>, AppError> {
// DB counts
let accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
let domains = sqlx::query_scalar!("SELECT COUNT(*) FROM domains")
.fetch_one(&state.db).await?.unwrap_or(0);
let databases = sqlx::query_scalar!("SELECT COUNT(*) FROM account_databases")
.fetch_one(&state.db).await?.unwrap_or(0);
let email_accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM email_accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
// Section binaries
let bin_dir = &state.config.bin_dir;
let sysinfo_resp = section::call(bin_dir, "nixpanel-sysinfo", "stats", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("sysinfo unavailable"));
let versions_resp = section::call(bin_dir, "nixpanel-sysinfo", "versions", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("versions unavailable"));
Ok(Json(DashboardResponse {
stats: DashboardStats {
accounts,
domains,
databases,
email_accounts,
},
sysinfo: sysinfo_resp.data.unwrap_or(serde_json::Value::Null),
versions: versions_resp.data.unwrap_or(serde_json::Value::Null),
}))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod accounts;
pub mod dashboard;
pub mod services;
+50
View File
@@ -0,0 +1,50 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, section, state::AppState};
pub async fn list_services(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let resp = section::call(
&state.config.bin_dir,
"nixpanel-services",
"list",
serde_json::Value::Null,
)
.map_err(|e| AppError::Internal(e))?;
Ok(Json(resp.data.unwrap_or(serde_json::json!([]))))
}
pub async fn service_action(
claims: Claims,
State(state): State<AppState>,
Path((name, action)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> {
let allowed_actions = ["start", "stop", "restart", "reload"];
if !allowed_actions.contains(&action.as_str()) {
return Err(AppError::BadRequest(format!("Unknown action: {}", action)));
}
let resp = section::call(
&state.config.bin_dir,
"nixpanel-services",
&action,
serde_json::json!({ "service": name }),
)
.map_err(|e| AppError::Internal(e))?;
if resp.success {
Ok(Json(serde_json::json!({
"success": true,
"message": resp.data
})))
} else {
Err(AppError::BadRequest(resp.error.unwrap_or_else(|| "Action failed".into())))
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Helpers for calling section binaries via stdin/stdout JSON.
use nixpanel_common::{SectionRequest, SectionResponse};
use std::io::Write;
use std::process::{Command, Stdio};
/// Call a section binary and return its parsed response.
/// `bin_dir` is e.g. "/opt/nixpanel/bin".
pub fn call(bin_dir: &str, binary: &str, action: &str, params: serde_json::Value)
-> anyhow::Result<SectionResponse>
{
let bin_path = format!("{}/{}", bin_dir, binary);
let req = SectionRequest { action: action.to_string(), params };
let req_json = serde_json::to_string(&req)?;
let mut child = Command::new(&bin_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start {}: {}", bin_path, e))?;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin.write_all(req_json.as_bytes())?;
stdin.write_all(b"\n")?;
}
let output = child.wait_with_output()?;
let text = String::from_utf8_lossy(&output.stdout);
let resp: SectionResponse = serde_json::from_str(text.trim())
.map_err(|e| anyhow::anyhow!("Bad response from {}: {} (raw: {})", binary, e, text.trim()))?;
Ok(resp)
}
+8
View File
@@ -0,0 +1,8 @@
use std::sync::Arc;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::MySqlPool,
pub config: Arc<Config>,
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "nixpanel-common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+47
View File
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Every section binary reads one of these from stdin.
#[derive(Debug, Serialize, Deserialize)]
pub struct SectionRequest {
pub action: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// Every section binary writes one of these to stdout.
#[derive(Debug, Serialize, Deserialize)]
pub struct SectionResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl SectionResponse {
pub fn ok<T: Serialize>(data: T) -> Self {
Self {
success: true,
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
error: None,
}
}
pub fn err(msg: impl ToString) -> Self {
Self {
success: false,
data: None,
error: Some(msg.to_string()),
}
}
}
/// Shared service descriptor used by both sysinfo and services sections.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceInfo {
pub name: String,
pub display: String,
pub status: String, // "running" | "stopped" | "warning"
pub pid: Option<u32>,
pub uptime: Option<String>,
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-services"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nixpanel-services"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+157
View File
@@ -0,0 +1,157 @@
//! nixpanel-services — service management section binary
//!
//! Protocol: read one SectionRequest JSON line from stdin,
//! write one SectionResponse JSON line to stdout.
//!
//! Actions:
//! list → [ServiceInfo]
//! start {service} → {success, message}
//! stop {service} → {success, message}
//! restart {service} → {success, message}
use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo};
use std::io::{self, BufRead};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Canonical list of managed services (systemd unit → display name).
const SERVICES: &[(&str, &str)] = &[
("apache2", "Apache HTTP Server"),
("mariadb", "MariaDB"),
("pdns", "PowerDNS"),
("exim4", "Exim (SMTP)"),
("dovecot", "Dovecot (IMAP)"),
("spamd", "SpamAssassin"),
("fail2ban", "Fail2ban"),
("proftpd", "ProFTPD"),
("nixpanel", "NixPanel API"),
];
fn systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new("systemctl").args(args).output()
}
fn is_active(unit: &str) -> &'static str {
match systemctl(&["is-active", "--quiet", unit]) {
Ok(out) if out.status.success() => "running",
_ => "stopped",
}
}
fn main_pid(unit: &str) -> Option<u32> {
let out = systemctl(&["show", unit, "--property=MainPID"]).ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let pid_str = text.trim().strip_prefix("MainPID=")?;
pid_str.parse::<u32>().ok().filter(|&p| p != 0)
}
fn active_enter_timestamp(unit: &str) -> Option<u64> {
let out = systemctl(&["show", unit, "--property=ActiveEnterTimestampMonotonic"]).ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let ts_str = text.trim().strip_prefix("ActiveEnterTimestampMonotonic=")?;
ts_str.parse::<u64>().ok()
}
fn monotonic_now_usec() -> u64 {
// /proc/uptime gives seconds since boot; multiply by 1_000_000 for µs
std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|s| {
s.split_whitespace()
.next()
.and_then(|n| n.parse::<f64>().ok())
})
.map(|secs| (secs * 1_000_000.0) as u64)
.unwrap_or(0)
}
fn format_uptime(elapsed_sec: u64) -> String {
let days = elapsed_sec / 86400;
let hours = (elapsed_sec % 86400) / 3600;
let mins = (elapsed_sec % 3600) / 60;
if days > 0 {
format!("{}d {}h", days, hours)
} else if hours > 0 {
format!("{}h {}m", hours, mins)
} else {
format!("{}m", mins)
}
}
fn service_info(unit: &str, display: &str) -> ServiceInfo {
let status = is_active(unit);
let pid = if status == "running" { main_pid(unit) } else { None };
let uptime = if status == "running" {
active_enter_timestamp(unit).map(|start| {
let now = monotonic_now_usec();
let secs = if now > start { (now - start) / 1_000_000 } else { 0 };
format_uptime(secs)
})
} else {
None
};
ServiceInfo {
name: unit.to_string(),
display: display.to_string(),
status: status.to_string(),
pid,
uptime,
}
}
fn list_services() -> SectionResponse {
let infos: Vec<ServiceInfo> = SERVICES
.iter()
.map(|(unit, display)| service_info(unit, display))
.collect();
SectionResponse::ok(infos)
}
fn control_service(unit: &str, action: &str) -> SectionResponse {
// Validate service name
if !SERVICES.iter().any(|(u, _)| *u == unit) {
return SectionResponse::err(format!("Unknown service: {}", unit));
}
// Validate action
let allowed = ["start", "stop", "restart", "reload"];
if !allowed.contains(&action) {
return SectionResponse::err(format!("Unknown action: {}", action));
}
match systemctl(&[action, unit]) {
Ok(out) if out.status.success() => SectionResponse::ok(serde_json::json!({
"message": format!("Service {} {}ed", unit, action)
})),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
SectionResponse::err(format!("systemctl {} {} failed: {}", action, unit, stderr.trim()))
}
Err(e) => SectionResponse::err(format!("Failed to run systemctl: {}", e)),
}
}
fn main() {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap_or(0);
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
Err(e) => SectionResponse::err(format!("Invalid request JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"list" => list_services(),
"start" | "stop" | "restart" | "reload" => {
let unit = req.params["service"]
.as_str()
.unwrap_or("");
if unit.is_empty() {
SectionResponse::err("Missing 'service' param")
} else {
control_service(unit, &req.action)
}
}
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-sysinfo"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nixpanel-sysinfo"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+247
View File
@@ -0,0 +1,247 @@
//! nixpanel-sysinfo — system information section binary
//!
//! Actions:
//! stats → {hostname, ip, os, kernel, uptime_sec, cpu_pct, ram_used_mb,
//! ram_total_mb, disk_used_gb, disk_total_gb, load_avg}
//! versions → {apache, php, mariadb, nixpanel}
use nixpanel_common::{SectionRequest, SectionResponse};
use std::fs;
use std::io::{self, BufRead};
use std::net::UdpSocket;
use std::process::Command;
/* ── helpers ──────────────────────────────────────────────────────────── */
fn cmd(prog: &str, args: &[&str]) -> String {
Command::new(prog)
.args(args)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
fn read_file(path: &str) -> String {
fs::read_to_string(path).unwrap_or_default()
}
/* ── hostname / IP ────────────────────────────────────────────────────── */
fn get_hostname() -> String {
fs::read_to_string("/etc/hostname")
.unwrap_or_default()
.trim()
.to_string()
}
fn get_ip() -> String {
// Quick UDP trick — no actual packet sent
UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("8.8.8.8:80")?;
s.local_addr()
})
.map(|a| a.ip().to_string())
.unwrap_or_else(|_| "0.0.0.0".into())
}
/* ── OS / kernel ──────────────────────────────────────────────────────── */
fn get_os() -> String {
let content = read_file("/etc/os-release");
for line in content.lines() {
if let Some(val) = line.strip_prefix("PRETTY_NAME=") {
return val.trim_matches('"').to_string();
}
}
"Unknown".into()
}
fn get_kernel() -> String {
cmd("uname", &["-r"])
}
/* ── uptime ───────────────────────────────────────────────────────────── */
fn get_uptime_sec() -> u64 {
read_file("/proc/uptime")
.split_whitespace()
.next()
.and_then(|s| s.parse::<f64>().ok())
.map(|s| s as u64)
.unwrap_or(0)
}
/* ── CPU ──────────────────────────────────────────────────────────────── */
/// Reads /proc/stat twice (100ms apart) to compute a 1-sample CPU %.
fn get_cpu_pct() -> f32 {
fn read_stat() -> Option<(u64, u64)> {
let content = fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().find(|l| l.starts_with("cpu "))?;
let nums: Vec<u64> = line
.split_whitespace()
.skip(1)
.filter_map(|n| n.parse().ok())
.collect();
if nums.len() < 4 {
return None;
}
let idle = nums[3];
let total: u64 = nums.iter().sum();
Some((idle, total))
}
let s1 = read_stat().unwrap_or((0, 1));
std::thread::sleep(std::time::Duration::from_millis(100));
let s2 = read_stat().unwrap_or((0, 1));
let d_total = s2.1.saturating_sub(s1.1);
let d_idle = s2.0.saturating_sub(s1.0);
if d_total == 0 {
return 0.0;
}
((d_total - d_idle) as f32 / d_total as f32 * 100.0 * 10.0).round() / 10.0
}
/* ── RAM ──────────────────────────────────────────────────────────────── */
fn get_ram() -> (u64, u64) {
// Returns (used_mb, total_mb)
let content = read_file("/proc/meminfo");
let mut total_kb: u64 = 0;
let mut available_kb: u64 = 0;
for line in content.lines() {
let mut parts = line.split_whitespace();
match parts.next() {
Some("MemTotal:") => { total_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
Some("MemAvailable:") => { available_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
_ => {}
}
}
let used_mb = (total_kb.saturating_sub(available_kb)) / 1024;
let total_mb = total_kb / 1024;
(used_mb, total_mb)
}
/* ── Disk ─────────────────────────────────────────────────────────────── */
fn get_disk() -> (u64, u64) {
// Returns (used_gb, total_gb) for /
use std::os::unix::fs::MetadataExt;
let stat = nix_statvfs("/");
stat.unwrap_or((0, 0))
}
fn nix_statvfs(path: &str) -> Option<(u64, u64)> {
// Use df as a fallback — parse "df -B1 /"
let out = Command::new("df")
.args(&["-B1", path])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().nth(1)?; // skip header
let mut parts = line.split_whitespace();
let _fs = parts.next()?;
let total = parts.next()?.parse::<u64>().ok()?;
let used = parts.next()?.parse::<u64>().ok()?;
Some((used / 1_073_741_824, total / 1_073_741_824))
}
/* ── Load average ─────────────────────────────────────────────────────── */
fn get_load_avg() -> [f32; 3] {
let content = read_file("/proc/loadavg");
let mut parts = content.split_whitespace();
let l1 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let l5 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let l15= parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
[l1, l5, l15]
}
/* ── Software versions ────────────────────────────────────────────────── */
fn apache_version() -> String {
let out = cmd("apache2", &["-v"]);
// "Server version: Apache/2.4.58 (Ubuntu)"
out.lines()
.find(|l| l.starts_with("Server version:"))
.and_then(|l| l.split('/').nth(1))
.and_then(|s| s.split_whitespace().next())
.unwrap_or("—")
.to_string()
}
fn php_version() -> String {
cmd("php8.3", &["--version"])
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("—")
.to_string()
}
fn mariadb_version() -> String {
let out = cmd("mariadb", &["--version"]);
// "mariadb Ver 15.1 Distrib 10.11.x-MariaDB ..."
out.split_whitespace()
.enumerate()
.find(|(_, w)| w.contains("MariaDB") || w.contains("Distrib"))
.map(|(i, _)| {
out.split_whitespace()
.nth(i.saturating_sub(1))
.unwrap_or("—")
.trim_end_matches(',')
.to_string()
})
.unwrap_or_else(|| "—".to_string())
}
/* ── Actions ──────────────────────────────────────────────────────────── */
fn action_stats() -> SectionResponse {
let (ram_used, ram_total) = get_ram();
let (disk_used, disk_total) = get_disk();
SectionResponse::ok(serde_json::json!({
"hostname": get_hostname(),
"ip": get_ip(),
"os": get_os(),
"kernel": get_kernel(),
"uptime_sec": get_uptime_sec(),
"cpu_pct": get_cpu_pct(),
"ram_used_mb": ram_used,
"ram_total_mb": ram_total,
"disk_used_gb": disk_used,
"disk_total_gb": disk_total,
"load_avg": get_load_avg(),
}))
}
fn action_versions() -> SectionResponse {
SectionResponse::ok(serde_json::json!({
"apache": apache_version(),
"php": php_version(),
"mariadb": mariadb_version(),
"nixpanel": env!("CARGO_PKG_VERSION"),
}))
}
/* ── Entry point ──────────────────────────────────────────────────────── */
fn main() {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap_or(0);
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
Err(e) => SectionResponse::err(format!("Invalid JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"stats" => action_stats(),
"versions" => action_versions(),
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}
+337 -298
View File
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import * as api from './api'
import type { DashboardData, ServiceInfo, Account } from './api'
/* ── Types ──────────────────────────────────────────────────────────────── */
type Page =
@@ -8,77 +10,43 @@ type Page =
| 'apache' | 'php' | 'services'
| 'settings' | 'logs' | 'packages'
/* ── Sidebar nav definition ─────────────────────────────────────────────── */
/* ── Nav ─────────────────────────────────────────────────────────────────── */
type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] }
const NAV_GROUPS: NavGroup[] = [
{
label: 'Overview',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: '▦' },
],
},
{
label: 'Account Management',
items: [
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
{ label: 'Account Management',items: [
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
{ id: 'createAccount',label: 'Create Account', icon: '➕' },
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
],
},
{
label: 'Domains & DNS',
items: [
]},
{ label: 'Domains & DNS', items: [
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
],
},
{
label: 'Databases',
items: [
{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' },
],
},
{
label: 'Email',
items: [
{ id: 'email', label: 'Email Routing', icon: '✉️' },
],
},
{
label: 'Security',
items: [
]},
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
{ label: 'Security', items: [
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
],
},
{
label: 'Server',
items: [
]},
{ label: 'Server', items: [
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
{ id: 'services', label: 'Service Manager', icon: '🔧' },
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
{ id: 'logs', label: 'System Logs', icon: '📜' },
{ id: 'settings', label: 'Server Config', icon: '🔩' },
],
},
]},
]
/* ── Sidebar ────────────────────────────────────────────────────────────── */
function Sidebar({
current, onNav, onLogout, collapsed, onToggle,
}: {
function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
current: Page; onNav: (p: Page) => void; onLogout: () => void
collapsed: boolean; onToggle: () => void
collapsed: boolean; onToggle: () => void; username: string
}) {
return (
<aside
className={`flex-shrink-0 bg-[#1a1f2e] border-r border-[#2a3044] flex flex-col min-h-screen transition-all duration-200 ${
collapsed ? 'w-14' : 'w-56'
}`}
>
{/* Logo row */}
<aside className={`flex-shrink-0 bg-[#1a1f2e] border-r border-[#2a3044] flex flex-col min-h-screen transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
<div className="h-14 flex items-center justify-between px-3 border-b border-[#2a3044]">
{!collapsed && (
<span className="text-base font-black tracking-tight text-white">
@@ -86,23 +54,16 @@ function Sidebar({
<span className="ml-1.5 text-[9px] font-bold bg-orange-500 text-white rounded px-1 py-0.5 uppercase tracking-wide align-middle">WHM</span>
</span>
)}
<button
onClick={onToggle}
className="text-gray-500 hover:text-white p-1 rounded transition-colors ml-auto"
title={collapsed ? 'Expand' : 'Collapse'}
>
<button onClick={onToggle} className="text-gray-500 hover:text-white p-1 rounded transition-colors ml-auto" title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
{/* Nav groups */}
<nav className="flex-1 overflow-y-auto py-2 scrollbar-thin">
<nav className="flex-1 overflow-y-auto py-2">
{NAV_GROUPS.map(group => (
<div key={group.label} className="mb-1">
{!collapsed && (
<p className="px-3 pt-3 pb-1 text-[9px] font-bold text-gray-600 uppercase tracking-widest">
{group.label}
</p>
<p className="px-3 pt-3 pb-1 text-[9px] font-bold text-gray-600 uppercase tracking-widest">{group.label}</p>
)}
{group.items.map(item => (
<button
@@ -123,23 +84,20 @@ function Sidebar({
))}
</nav>
{/* User / logout */}
<div className="border-t border-[#2a3044] p-2">
{!collapsed && (
<div className="flex items-center gap-2 px-1 py-1.5 mb-1">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">A</div>
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-white truncate">admin</p>
<p className="text-xs font-semibold text-white truncate">{username}</p>
<p className="text-[10px] text-gray-500">root</p>
</div>
</div>
)}
<button
onClick={onLogout}
title="Sign out"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-500 hover:bg-[#252b3d] hover:text-red-400 transition-colors ${
collapsed ? 'justify-center' : ''
}`}
<button onClick={onLogout} title="Sign out"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-500 hover:bg-[#252b3d] hover:text-red-400 transition-colors ${collapsed ? 'justify-center' : ''}`}
>
<span>⏻</span>
{!collapsed && 'Sign out'}
@@ -149,82 +107,108 @@ function Sidebar({
)
}
/* ── Stat bar (top of dashboard) ────────────────────────────────────────── */
function StatBar() {
/* ── Stat bar ────────────────────────────────────────────────────────────── */
function StatBar({ data }: { data: DashboardData | null }) {
const s = data?.stats
const si = data?.sysinfo
const ramPct = si && si.ram_total_mb > 0
? Math.round((si.ram_used_mb / si.ram_total_mb) * 100)
: null
const diskPct = si && si.disk_total_gb > 0
? Math.round((si.disk_used_gb / si.disk_total_gb) * 100)
: null
function barColor(pct: number) {
return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'
}
const stats = [
{ label: 'Hosting Accounts', value: '0', unit: '', color: 'text-orange-400' },
{ label: 'Domains', value: '0', unit: '', color: 'text-blue-400' },
{ label: 'Databases', value: '0', unit: '', color: 'text-purple-400' },
{ label: 'Email Accounts', value: '0', unit: '', color: 'text-cyan-400' },
{ label: 'Disk Used', value: '—', unit: '', color: 'text-emerald-400' },
{ label: 'RAM Used', value: '—', unit: '', color: 'text-yellow-400' },
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
{ label: 'Disk Used', value: si ? `${si.disk_used_gb}` : '—', unit: 'GB', color: diskPct && diskPct > 80 ? 'text-red-400' : 'text-emerald-400', bar: diskPct },
{ label: 'RAM Used', value: si ? `${Math.round(si.ram_used_mb / 1024 * 10) / 10}` : '—', unit: 'GB', color: ramPct && ramPct > 80 ? 'text-red-400' : 'text-yellow-400', bar: ramPct },
]
return (
<div className="grid grid-cols-3 lg:grid-cols-6 gap-0 border border-[#2a3044] rounded-lg overflow-hidden">
{stats.map((s, i) => (
<div
key={s.label}
className={`bg-[#1a1f2e] px-4 py-3 ${i < stats.length - 1 ? 'border-r border-[#2a3044]' : ''}`}
>
<div key={s.label} className={`bg-[#1a1f2e] px-4 py-3 ${i < stats.length - 1 ? 'border-r border-[#2a3044]' : ''}`}>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{s.label}</p>
<p className={`text-xl font-black mt-0.5 ${s.color}`}>{s.value}<span className="text-xs font-normal text-gray-500 ml-1">{s.unit}</span></p>
<p className={`text-xl font-black mt-0.5 ${s.color}`}>
{s.value}<span className="text-xs font-normal text-gray-500 ml-1">{s.unit}</span>
</p>
{s.bar !== null && (
<div className="mt-1.5 h-1 bg-[#2a3044] rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor(s.bar)}`} style={{ width: `${s.bar}%` }} />
</div>
)}
</div>
))}
</div>
)
}
/* ── Service status table ───────────────────────────────────────────────── */
type SvcStatus = 'running' | 'stopped' | 'warning'
function ServiceTable() {
const services: { name: string; status: SvcStatus; pid?: string; uptime?: string }[] = [
{ name: 'Apache HTTP Server', status: 'running', pid: '1234', uptime: '2d 4h' },
{ name: 'MariaDB', status: 'running', pid: '1238', uptime: '2d 4h' },
{ name: 'PowerDNS', status: 'running', pid: '1242', uptime: '2d 4h' },
{ name: 'Exim (SMTP)', status: 'running', pid: '1251', uptime: '2d 4h' },
{ name: 'Dovecot (IMAP)', status: 'running', pid: '1265', uptime: '2d 4h' },
{ name: 'SpamAssassin', status: 'running', pid: '1280', uptime: '2d 4h' },
{ name: 'Fail2ban', status: 'running', pid: '1291', uptime: '2d 4h' },
{ name: 'ProFTPD', status: 'stopped' },
]
const badge: Record<SvcStatus, string> = {
/* ── Service table ───────────────────────────────────────────────────────── */
function ServiceTable({ services, loading, onAction }: {
services: ServiceInfo[]
loading: boolean
onAction: (name: string, action: 'start' | 'stop' | 'restart') => void
}) {
const badge: Record<string, string> = {
running: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
stopped: 'bg-red-500/20 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
}
const running = services.filter(s => s.status === 'running').length
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a3044] flex items-center justify-between">
<h3 className="text-sm font-bold text-white">Service Manager</h3>
<span className="text-xs text-gray-500">7 / 8 running</span>
{loading
? <span className="text-xs text-gray-600">Loading…</span>
: <span className="text-xs text-gray-500">{running} / {services.length} running</span>
}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044]">
<th className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider">Service</th>
<th className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden md:table-cell">PID</th>
<th className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden md:table-cell">Uptime</th>
<th className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider">Actions</th>
{['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => (
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden-last-two md:table-cell first:table-cell">{h}</th>
))}
</tr>
</thead>
<tbody>
{services.map(svc => (
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />
Loading services…
</td>
</tr>
) : services.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data available</td>
</tr>
) : services.map(svc => (
<tr key={svc.name} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.name}</td>
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.display}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${badge[svc.status]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${svc.status === 'running' ? 'bg-emerald-400' : svc.status === 'stopped' ? 'bg-red-400' : 'bg-yellow-400'}`} />
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${badge[svc.status] ?? badge.warning}`}>
<span className={`w-1.5 h-1.5 rounded-full ${svc.status === 'running' ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
{svc.status.charAt(0).toUpperCase() + svc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid || '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime || '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid ?? '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
<button onClick={() => onAction(svc.name, 'restart')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
</div>
</td>
</tr>
@@ -235,17 +219,17 @@ function ServiceTable() {
)
}
/* ── Quick action tiles (cPanel icon-grid style) ────────────────────────── */
/* ── Quick action grid ───────────────────────────────────────────────────── */
function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
const actions: { label: string; icon: string; page: Page; desc: string }[] = [
{ label: 'Create Account', icon: '👤', page: 'createAccount', desc: 'Add hosting account' },
{ label: 'Zone Manager', icon: '🌐', page: 'domains', desc: 'Manage DNS zones' },
{ label: 'New Database', icon: '🗄️', page: 'databases', desc: 'Create MariaDB DB' },
{ label: 'Issue SSL', icon: '🔒', page: 'ssl', desc: "Let's Encrypt cert" },
{ label: 'Run Backup', icon: '💾', page: 'backups', desc: 'Full server backup' },
{ label: 'PHP Manager', icon: '🐘', page: 'php', desc: 'Switch PHP version' },
{ label: 'Firewall', icon: '🛡️', page: 'firewall', desc: 'Edit CSF rules' },
{ label: 'System Logs', icon: '📜', page: 'logs', desc: 'Apache, error logs' },
const actions = [
{ label: 'Create Account', icon: '👤', page: 'createAccount' as Page, desc: 'Add hosting account' },
{ label: 'Zone Manager', icon: '🌐', page: 'domains' as Page, desc: 'Manage DNS zones' },
{ label: 'New Database', icon: '🗄️', page: 'databases' as Page, desc: 'Create MariaDB DB' },
{ label: 'Issue SSL', icon: '🔒', page: 'ssl' as Page, desc: "Let's Encrypt cert" },
{ label: 'Run Backup', icon: '💾', page: 'backups' as Page, desc: 'Full server backup' },
{ label: 'PHP Manager', icon: '🐘', page: 'php' as Page, desc: 'Switch PHP version' },
{ label: 'Firewall', icon: '🛡️', page: 'firewall' as Page, desc: 'Edit CSF rules' },
{ label: 'System Logs', icon: '📜', page: 'logs' as Page, desc: 'Apache, error logs' },
]
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
@@ -254,13 +238,8 @@ function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-0">
{actions.map((a, i) => (
<button
key={a.label}
onClick={() => onNav(a.page)}
className={`flex flex-col items-center text-center p-4 hover:bg-orange-500/10 hover:border-orange-500/20 transition-all group
${i % 4 !== 3 ? 'border-r border-[#2a3044]' : ''}
${i < actions.length - 4 ? 'border-b border-[#2a3044]' : ''}
`}
<button key={a.label} onClick={() => onNav(a.page)}
className={`flex flex-col items-center text-center p-4 hover:bg-orange-500/10 transition-all group ${i % 4 !== 3 ? 'border-r border-[#2a3044]' : ''} ${i < actions.length - 4 ? 'border-b border-[#2a3044]' : ''}`}
>
<span className="text-2xl mb-2 group-hover:scale-110 transition-transform">{a.icon}</span>
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{a.label}</span>
@@ -272,16 +251,18 @@ function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
)
}
/* ── Server info bar ────────────────────────────────────────────────────── */
function ServerInfoBar() {
/* ── Server info bar ─────────────────────────────────────────────────────── */
function ServerInfoBar({ data }: { data: DashboardData | null }) {
const si = data?.sysinfo
const v = data?.versions
const info = [
{ label: 'Hostname', value: 'server.example.com' },
{ label: 'OS', value: 'Ubuntu 24.04 LTS' },
{ label: 'Kernel', value: '6.8.0-51-generic' },
{ label: 'PHP', value: '8.3 / 8.2' },
{ label: 'MariaDB', value: '10.11' },
{ label: 'Apache', value: '2.4' },
{ label: 'NixPanel', value: 'v0.1.0' },
{ label: 'Hostname', value: si?.hostname ?? '—' },
{ label: 'OS', value: si?.os ?? '—' },
{ label: 'Kernel', value: si?.kernel ?? '—' },
{ label: 'PHP', value: v?.php ?? '—' },
{ label: 'MariaDB', value: v?.mariadb ?? '—' },
{ label: 'Apache', value: v?.apache ?? '—' },
{ label: 'NixPanel', value: v?.nixpanel ?? '—' },
]
return (
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg px-4 py-3 flex flex-wrap gap-x-6 gap-y-2">
@@ -291,81 +272,162 @@ function ServerInfoBar() {
<span className="text-xs text-gray-300 font-medium">{i.value}</span>
</div>
))}
{si && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">Load:</span>
<span className="text-xs text-gray-300 font-medium">{si.load_avg[0].toFixed(2)}</span>
</div>
)}
</div>
)
}
/* ── Dashboard page ─────────────────────────────────────────────────────── */
/* ── Dashboard page ──────────────────────────────────────────────────────── */
function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
const [dashData, setDashData] = useState<DashboardData | null>(null)
const [services, setServices] = useState<ServiceInfo[]>([])
const [svcLoading, setSvcLoading] = useState(true)
const [actionMsg, setActionMsg] = useState('')
useEffect(() => {
api.getDashboard().then(setDashData).catch(console.error)
}, [])
useEffect(() => {
api.getServices().then(s => { setServices(s); setSvcLoading(false) }).catch(() => setSvcLoading(false))
}, [])
const handleServiceAction = useCallback(async (name: string, action: 'start' | 'stop' | 'restart') => {
setActionMsg(`${action}ing ${name}…`)
try {
await api.serviceAction(name, action)
// Refresh services
const updated = await api.getServices()
setServices(updated)
setActionMsg(`${name} ${action}ed`)
} catch (e: any) {
setActionMsg(`Error: ${e.message}`)
}
setTimeout(() => setActionMsg(''), 3000)
}, [])
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-black text-white">Server Overview</h1>
<p className="text-xs text-gray-500 mt-0.5">Welcome back, admin — NixServer WHM</p>
<p className="text-xs text-gray-500 mt-0.5">
{dashData?.sysinfo
? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}`
: 'Welcome back, admin — NixServer WHM'}
</p>
</div>
<div className="flex items-center gap-3">
{actionMsg && (
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">
{actionMsg}
</span>
)}
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
All systems operational
</span>
</div>
</div>
<StatBar />
<ServerInfoBar />
<StatBar data={dashData} />
<ServerInfoBar data={dashData} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
<ServiceTable />
<ServiceTable services={services} loading={svcLoading} onAction={handleServiceAction} />
<QuickActions onNav={onNav} />
</div>
</div>
)
}
/* ── Account list page ──────────────────────────────────────────────────── */
function ListAccounts() {
/* ── List accounts ───────────────────────────────────────────────────────── */
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
const [accounts, setAccounts] = useState<Account[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
api.getAccounts()
.then(setAccounts)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const filtered = accounts.filter(a =>
a.username.includes(search) || a.domain.includes(search)
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-black text-white">Hosting Accounts</h1>
<button className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
+ Create Account
</button>
</div>
{/* Filter bar */}
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3 flex gap-3">
<input
type="text"
placeholder="Search accounts, domains, usernames…"
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search accounts, domains…"
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/50"
/>
<select className="bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none">
<option>All Packages</option>
</select>
<select className="bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none">
<option>All Status</option>
<option>Active</option>
<option>Suspended</option>
<option>All Status</option><option>Active</option><option>Suspended</option>
</select>
</div>
{/* Table */}
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
{['Username', 'Domain', 'Package', 'Disk', 'BW', 'Status', 'Actions'].map(h => (
{['Username', 'Domain', 'Package', 'Disk Quota', 'Status', 'Created', 'Actions'].map(h => (
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={7} className="px-4 py-16 text-center">
{loading ? (
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading…
</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-16 text-center">
<div className="text-3xl mb-3">👤</div>
<p className="text-gray-500 text-sm">No accounts yet. <button className="text-orange-400 hover:underline">Create your first account →</button></p>
<p className="text-gray-500 text-sm">
{search ? 'No accounts match your search.' : <>No accounts yet. <button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
</p>
</td></tr>
) : filtered.map(acc => (
<tr key={acc.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{acc.username}</td>
<td className="px-4 py-2.5 text-gray-400">{acc.domain}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.package_name}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${
acc.status === 'active'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : 'bg-red-400'}`} />
{acc.status.charAt(0).toUpperCase() + acc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(acc.created_at).toLocaleDateString()}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-orange-400 text-gray-400 transition-colors">Edit</button>
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Suspend</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -373,98 +435,126 @@ function ListAccounts() {
)
}
/* ── Create account form ────────────────────────────────────────────────── */
function CreateAccount() {
/* ── Create account form ─────────────────────────────────────────────────── */
function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
const [form, setForm] = useState({
username: '', domain: '', email: '', password: '',
package_name: 'Default', disk_quota_mb: 10240, bandwidth_mb: 0,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError(''); setSuccess('')
try {
const acc = await api.createAccount({
...form,
disk_quota_mb: Number(form.disk_quota_mb),
bandwidth_mb: Number(form.bandwidth_mb),
})
setSuccess(`Account "${acc.username}" created for ${acc.domain}`)
setTimeout(() => onNav('listAccounts'), 1500)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
const Field = ({ label, name, type = 'text', placeholder = '' }: { label: string; name: string; type?: string; placeholder?: string }) => (
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{label}</label>
<input type={type} value={String((form as any)[name])} onChange={e => set(name, e.target.value)}
placeholder={placeholder}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60"
/>
</div>
)
return (
<div className="max-w-2xl space-y-5">
<h1 className="text-lg font-black text-white">Create Hosting Account</h1>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
{/* Domain */}
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Domain Information</h2>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Domain Name</label>
<input type="text" placeholder="example.com" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
</div>
</div>
<Field label="Domain Name" name="domain" placeholder="example.com" />
</div>
{/* Credentials */}
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Account Credentials</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Username</label>
<input type="text" placeholder="username" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Password</label>
<input type="password" placeholder="••••••••" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
</div>
<Field label="Username" name="username" placeholder="username" />
<Field label="Password" name="password" type="password" placeholder="••••••••" />
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Email Address</label>
<input type="email" placeholder="[email protected]" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
<Field label="Email Address" name="email" type="email" placeholder="[email protected]" />
</div>
</div>
</div>
{/* Package & limits */}
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Resource Limits</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Package</label>
<select className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
<select value={form.package_name} onChange={e => set('package_name', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
<option>Default</option>
<option>Starter</option>
<option>Professional</option>
<option>Enterprise</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Disk Quota (MB)</label>
<input type="number" placeholder="10240" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
<input type="number" value={form.disk_quota_mb} onChange={e => set('disk_quota_mb', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo)</label>
<input type="number" placeholder="Unlimited" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Max Databases</label>
<input type="number" placeholder="Unlimited" className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60" />
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo, 0=∞)</label>
<input type="number" value={form.bandwidth_mb} onChange={e => set('bandwidth_mb', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
/>
</div>
</div>
</div>
<div className="p-5 flex gap-3">
<button className="px-5 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold rounded transition-colors">
Create Account
<button type="submit" disabled={loading}
className="px-5 py-2 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
{loading && <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
{loading ? 'Creating…' : 'Create Account'}
</button>
<button className="px-5 py-2 bg-[#0f1520] border border-[#2a3044] hover:border-gray-500 text-gray-400 hover:text-white text-sm font-medium rounded transition-colors">
<button type="button" onClick={() => onNav('listAccounts')}
className="px-5 py-2 bg-[#0f1520] border border-[#2a3044] hover:border-gray-500 text-gray-400 hover:text-white text-sm rounded transition-colors">
Cancel
</button>
</div>
</div>
</form>
</div>
)
}
/* ── Generic placeholder for unbuilt modules ────────────────────────────── */
/* ── Placeholder ─────────────────────────────────────────────────────────── */
function Placeholder({ title, icon }: { title: string; icon: string }) {
return (
<div className="flex flex-col items-center justify-center h-72 text-center">
<div className="text-5xl mb-4">{icon}</div>
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
<p className="text-gray-600 text-sm max-w-sm">
This module is under active development and will be available in an upcoming release.
</p>
<div className="mt-4 px-3 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded text-xs text-orange-400 font-semibold">
Coming Soon
</div>
<p className="text-gray-600 text-sm max-w-sm">This module is under active development and will be available in an upcoming release.</p>
<div className="mt-4 px-3 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded text-xs text-orange-400 font-semibold">Coming Soon</div>
</div>
)
}
/* ── Page icon map ──────────────────────────────────────────────────────── */
const PAGE_META: Record<Page, { title: string; icon: string }> = {
dashboard: { title: 'Server Dashboard', icon: '▦' },
accounts: { title: 'Accounts', icon: '👤' },
@@ -485,72 +575,42 @@ const PAGE_META: Record<Page, { title: string; icon: string }> = {
logs: { title: 'System Logs', icon: '📜' },
}
/* ── Admin panel shell ──────────────────────────────────────────────────── */
function AdminPanel() {
/* ── Admin panel shell ───────────────────────────────────────────────────── */
function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
const [page, setPage] = useState<Page>('dashboard')
const [loggedIn, setLoggedIn] = useState(true)
const [collapsed, setCollapsed] = useState(false)
if (!loggedIn) return null
const meta = PAGE_META[page]
function renderPage() {
switch (page) {
case 'dashboard': return <Dashboard onNav={setPage} />
case 'listAccounts': return <ListAccounts />
case 'createAccount': return <CreateAccount />
case 'listAccounts': return <ListAccounts onNav={setPage} />
case 'createAccount': return <CreateAccount onNav={setPage} />
default: return <Placeholder title={meta.title} icon={meta.icon} />
}
}
return (
<div className="flex min-h-screen bg-[#0f1520] text-white">
<Sidebar
current={page}
onNav={setPage}
onLogout={() => setLoggedIn(false)}
collapsed={collapsed}
onToggle={() => setCollapsed(c => !c)}
/>
<Sidebar current={page} onNav={setPage} onLogout={onLogout} collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} username={username} />
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar */}
<header className="h-14 border-b border-[#2a3044] bg-[#1a1f2e] flex items-center px-6 justify-between gap-4 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-gray-600 text-sm">{meta.icon}</span>
<h2 className="font-semibold text-gray-200 text-sm truncate">{meta.title}</h2>
</div>
<div className="flex items-center gap-3">
{/* Server info pills */}
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">
Ubuntu 24.04
</span>
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">
Apache 2.4
</span>
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">
PHP 8.3
</span>
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">Ubuntu 24.04</span>
</div>
{/* Alerts */}
<button className="relative text-gray-500 hover:text-white p-1.5 transition-colors">
🔔
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-orange-500 rounded-full" />
</button>
{/* Avatar */}
<div className="flex items-center gap-2 pl-3 border-l border-[#2a3044]">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold">A</div>
<span className="text-sm font-semibold text-gray-300 hidden sm:block">admin</span>
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold">{username.charAt(0).toUpperCase()}</div>
<span className="text-sm font-semibold text-gray-300 hidden sm:block">{username}</span>
</div>
</div>
</header>
{/* Breadcrumb */}
{page !== 'dashboard' && (
<div className="px-6 py-2 border-b border-[#2a3044] bg-[#0f1520] flex items-center gap-1.5 text-xs text-gray-600">
<button onClick={() => setPage('dashboard')} className="hover:text-orange-400 transition-colors">Dashboard</button>
@@ -559,12 +619,8 @@ function AdminPanel() {
</div>
)}
{/* Page content */}
<main className="flex-1 p-6 overflow-auto">
{renderPage()}
</main>
<main className="flex-1 p-6 overflow-auto">{renderPage()}</main>
{/* Footer */}
<footer className="border-t border-[#2a3044] px-6 py-2 flex items-center justify-between text-[10px] text-gray-700">
<span>NixPanel WHM v0.1.0 · Ubuntu 24.04 LTS</span>
<span>© 2025 NixPanel</span>
@@ -574,29 +630,44 @@ function AdminPanel() {
)
}
/* ── Login page ─────────────────────────────────────────────────────────── */
/* ── Login ───────────────────────────────────────────────────────────────── */
export default function App() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = (e: React.FormEvent) => {
e.preventDefault()
if (!username || !password) {
setError('Username and password are required.')
return
// Restore session from localStorage
useEffect(() => {
if (api.isLoggedIn()) {
const stored = localStorage.getItem('nixpanel_username')
setLoggedInAs(stored ?? 'admin')
}
setLoading(true)
setError('')
setTimeout(() => {
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError('')
try {
const result = await api.login(username, password)
localStorage.setItem('nixpanel_username', result.user.username)
setLoggedInAs(result.user.username)
} catch (err: any) {
setError(err.message === 'Unauthorized' ? 'Invalid username or password.' : err.message)
} finally {
setLoading(false)
setIsLoggedIn(true)
}, 600)
}
}
if (isLoggedIn) return <AdminPanel />
const handleLogout = () => {
api.clearToken()
localStorage.removeItem('nixpanel_username')
setLoggedInAs(null)
setUsername(''); setPassword('')
}
if (loggedInAs) return <AdminPanel username={loggedInAs} onLogout={handleLogout} />
return (
<div className="min-h-screen flex bg-[#0f1520]">
@@ -610,7 +681,6 @@ export default function App() {
<div className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">WHM Control Panel</div>
</div>
</div>
<div className="space-y-5">
{[
{ icon: '🌐', title: 'Full DNS Control', desc: 'PowerDNS-backed zone management' },
@@ -629,21 +699,18 @@ export default function App() {
))}
</div>
</div>
<p className="text-[10px] text-gray-700">NixPanel WHM v0.1.0 · Secured by Fail2ban</p>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
{/* Mobile logo */}
<div className="lg:hidden text-center mb-8">
<span className="text-3xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
<p className="text-xs text-gray-600 mt-1">WHM Control Panel</p>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl shadow-2xl overflow-hidden">
{/* Card header */}
<div className="bg-orange-500 px-6 py-4">
<h1 className="text-white font-bold text-base">Administrator Login</h1>
<p className="text-orange-200 text-xs mt-0.5">NixServer WHM · Root Access</p>
@@ -655,51 +722,23 @@ export default function App() {
<span>⚠️</span> {error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">
Username
</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
required
autoFocus
autoComplete="username"
placeholder="admin"
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="admin"
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
placeholder="••••••••••••"
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••••••"
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-900/40 flex items-center justify-center gap-2"
>
<button type="submit" disabled={loading}
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-900/40 flex items-center justify-center gap-2">
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Authenticating…
</>
) : (
'Sign in to WHM'
)}
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Authenticating…</>
) : 'Sign in to WHM'}
</button>
</form>
+137
View File
@@ -0,0 +1,137 @@
/* NixServer API client — all calls go to /api/ (proxied to Rust backend) */
const BASE = '/api'
/* ── Auth token ──────────────────────────────────────────────────────── */
export function getToken(): string | null {
return localStorage.getItem('nixpanel_token')
}
export function setToken(t: string) {
localStorage.setItem('nixpanel_token', t)
}
export function clearToken() {
localStorage.removeItem('nixpanel_token')
}
export function isLoggedIn(): boolean {
return !!getToken()
}
/* ── Core fetch wrapper ──────────────────────────────────────────────── */
async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
})
if (res.status === 401) {
clearToken()
window.location.reload()
throw new Error('Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json()
}
/* ── Auth ────────────────────────────────────────────────────────────── */
export interface LoginResult {
token: string
user: { id: number; username: string; role: string }
}
export async function login(username: string, password: string): Promise<LoginResult> {
const result = await apiFetch<LoginResult>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
setToken(result.token)
return result
}
/* ── Dashboard ───────────────────────────────────────────────────────── */
export interface DashboardStats {
accounts: number
domains: number
databases: number
email_accounts: number
}
export interface SysInfo {
hostname: string
ip: string
os: string
kernel: string
uptime_sec: number
cpu_pct: number
ram_used_mb: number
ram_total_mb: number
disk_used_gb: number
disk_total_gb: number
load_avg: [number, number, number]
}
export interface Versions {
apache: string
php: string
mariadb: string
nixpanel: string
}
export interface DashboardData {
stats: DashboardStats
sysinfo: SysInfo | null
versions: Versions | null
}
export async function getDashboard(): Promise<DashboardData> {
return apiFetch('/dashboard')
}
/* ── Services ────────────────────────────────────────────────────────── */
export interface ServiceInfo {
name: string
display: string
status: 'running' | 'stopped' | 'warning'
pid: number | null
uptime: string | null
}
export async function getServices(): Promise<ServiceInfo[]> {
return apiFetch('/services')
}
export async function serviceAction(
name: string,
action: 'start' | 'stop' | 'restart'
): Promise<{ success: boolean; message: string }> {
return apiFetch(`/services/${name}/${action}`, { method: 'POST' })
}
/* ── Accounts ────────────────────────────────────────────────────────── */
export interface Account {
id: number
username: string
domain: string
email: string
package_name: string
disk_quota_mb: number
bandwidth_mb: number
status: 'active' | 'suspended' | 'terminated'
created_at: string
}
export async function getAccounts(): Promise<Account[]> {
return apiFetch('/accounts')
}
export async function createAccount(data: {
username: string
domain: string
email: string
password: string
package_name?: string
disk_quota_mb?: number
bandwidth_mb?: number
}): Promise<Account> {
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) })
}
+6
View File
@@ -11,5 +11,11 @@ export default defineConfig({
server: {
port: 3000,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})