Compare commits

101 Commits

Author SHA1 Message Date
github-actions[bot] 85cbfd554c chore: bump version to 1.0.72 [skip ci] 2026-06-23 16:08:04 +00:00
myron 33c86171d6 fix: add null-coalescing defaults for all package update fields to suppress undefined key warnings 2026-06-23 16:07:47 +00:00
github-actions[bot] bc727eeedc chore: bump version to 1.0.71 [skip ci] 2026-06-23 04:28:02 +00:00
myron 7ecab86ef9 chore: gitignore adminer.php (500KB binary, deployed directly) 2026-06-23 04:27:52 +00:00
github-actions[bot] 7cce8cfef0 chore: bump version to 1.0.70 [skip ci] 2026-06-23 03:13:53 +00:00
myron 3a1746b0c0 fix: all 6 code review findings
1. admin.js: dashboard setTimeout was after return (dead code) — restructured
   to assign template to const html, run setTimeout, then return html

2. DockerManager.php createStack: replaced SELECT LAST_INSERT_ID() with
   db->insert() which already returns lastInsertId correctly for SQLite

3. DockerManager.php setQuota: replaced ON DUPLICATE KEY UPDATE / VALUES()
   MySQL syntax with SQLite-compatible ON CONFLICT(user_id) DO UPDATE SET
   excluded.col syntax

4. post-restore.sh: PHP helper file now written ONCE at start of step 4
   before any call to it (was written AFTER first call, causing silent failure)

5. post-restore.sh: git pull exit code now captured before pipeline (the
   while-read loop always exited 0, masking pull failures)

6. uninstall.sh: tar backup now aborts on failure (previously 2>/dev/null
   swallowed errors and rm -rf destroyed source unconditionally); also
   rm -f → rm -rf for .service.d drop-in directory
2026-06-23 03:13:42 +00:00
github-actions[bot] cba75b3356 chore: bump version to 1.0.69 [skip ci] 2026-06-23 01:53:43 +00:00
myron d24ea40505 feat: merge Dashboard + Server Status (careful surgical approach)
- Added history chart to dashboard: fetches stats/server API, renders
  24-hour CPU/RAM/Disk chart with Chart.js lazy-loaded
- setTimeout properly INSIDE function before closing brace
- Removed ONLY serverStatus() function body (2521 chars), kept initStatsChart
- pages object redirects server-status → dashboard
- Removed server-status from admin sidebar nav
- All 26 functions intact, backticks balanced, accounts/packages/DNS all kept
2026-06-23 01:53:35 +00:00
github-actions[bot] 18971f7f10 chore: bump version to 1.0.68 [skip ci] 2026-06-23 00:50:59 +00:00
myron e805e55120 feat: #40 Linux uninstaller
- Full backup before removal: DB, all account home dirs, nginx vhosts,
  SSL certs, DNS zones, mail config, logs, systemd units, cron jobs
- Backup compressed to /tmp/novacpx-uninstall-backup-TIMESTAMP.tar.gz
- Prints scp command + temp HTTP server option to download backup
- Requires confirmation (or --yes flag to skip)
- Removes: hosting accounts + Linux users, PHP-FPM pools, nginx vhosts,
  novacpx-web systemd unit, sudoers rules, cron jobs, opendkim keys,
  DNS zones, postfix virtual tables, fail2ban filters
- Removes all NovaCPX directories: /srv/novacpx /opt/novacpx-src
  /opt/novacpx /var/lib/novacpx /var/log/novacpx /etc/novacpx
- Restores nginx default site
- Leaves base services (nginx, php-fpm, postfix, etc.) running
- Usage: bash uninstall.sh [--yes]
2026-06-23 00:50:50 +00:00
github-actions[bot] b29a988129 chore: bump version to 1.0.67 [skip ci] 2026-06-23 00:43:12 +00:00
myron 87281cb923 feat: #50 improved post-restore script v2
- Cleans orphaned DB user records before recreating webacct
- Handles both orphaned-Linux-user and orphaned-DB-user cases
- Pulls latest NovaCPX code + runs migrations
- Bumps PHP-FPM pm.max_children to 20 if still at default 5
- Creates /tmp/_nova_create_webacct.php helper separately (avoids here-doc PHP issues)
- Deploys both index.html and notes.php from web-dashboard repo
- Disables Apache2 if running
- --no-git flag to skip code pull
- Comprehensive logging with ✓/⚠ indicators
2026-06-23 00:43:04 +00:00
github-actions[bot] 5d9fc30124 chore: bump version to 1.0.66 [skip ci] 2026-06-22 23:59:08 +00:00
myron a98f08e45a fix: add Adminer install/remove handler to db-tools-stream; fix sudo tty for phpMyAdmin 2026-06-22 23:58:59 +00:00
github-actions[bot] c872ee2449 chore: bump version to 1.0.65 [skip ci] 2026-06-22 21:29:23 +00:00
myron 568e0a0891 feat: #41 #43 — phpMyAdmin + Adminer + PostgreSQL in DB Manager
- Adminer installed at /adminer.php (MySQL + PostgreSQL)
- db-tools API now detects adminer.php file and returns its URL
- Tool cards: phpMyAdmin, Adminer (MySQL/PG), pgAdmin4
- Open buttons use API-provided URL (adminer.php for Adminer)
- Separate MySQL and PostgreSQL database sections in DB Manager
- PostgreSQL section has direct link to Adminer PG mode
- #42 Docker: already complete (full docker page with all tabs)
2026-06-22 21:29:12 +00:00
github-actions[bot] 732b7d16ca chore: bump version to 1.0.64 [skip ci] 2026-06-22 21:20:07 +00:00
myron e209df0dc2 revert: restore admin.js to last known-good state (01b0995) 2026-06-22 21:19:54 +00:00
github-actions[bot] 9691cc0853 chore: bump version to 1.0.63 [skip ci] 2026-06-22 21:13:39 +00:00
myron 42055ccdcc fix: dashboard setTimeout inside function, history chart properly merged 2026-06-22 21:13:28 +00:00
github-actions[bot] 2327f1b958 chore: bump version to 1.0.62 [skip ci] 2026-06-22 19:13:17 +00:00
myron 3ca3a1dae6 fix: surgical dashboard merge — only remove serverStatus(), keep all other pages
Previous merge accidentally deleted 38KB of page functions (accounts, packages,
DNS, etc.) by using wrong boundary. This time only removes the serverStatus()
function body. Dashboard now includes history chart + setTimeout to render it.
All other pages intact.
2026-06-22 19:13:07 +00:00
github-actions[bot] a91b67de10 chore: bump version to 1.0.61 [skip ci] 2026-06-22 19:03:57 +00:00
myron d39559a058 feat: merge Dashboard + Server Status into one page (admin panel) 2026-06-22 19:03:41 +00:00
github-actions[bot] 01b099509f chore: bump version to 1.0.60 [skip ci] 2026-06-22 14:24:25 +00:00
myron 844f571231 fix: docker catalog CHECK constraint — change status='starting' to 'pending'
'starting' is not in the CHECK constraint list. Using 'pending' which is
already set by the INSERT in createStack (the UPDATE was redundant anyway).
2026-06-22 14:24:12 +00:00
github-actions[bot] 06e43b1297 chore: bump version to 1.0.59 [skip ci] 2026-06-22 12:51:59 +00:00
myron 960a29f508 fix: image-remove use POST not DELETE (body was stripped by proxy); keep list visible on refresh 2026-06-22 12:51:49 +00:00
github-actions[bot] 63ec5f48b3 chore: bump version to 1.0.58 [skip ci] 2026-06-22 12:44:59 +00:00
myron 6aa96e6265 fix: docker container actions update row immediately (optimistic UI), keep list visible during reload
- Row badge updates to 'stopping…'/'starting…' instantly on click
- Buttons disabled while action runs so no double-clicks
- List stays visible while refreshing after action (no blank flash)
- container-remove changed to POST so body passes through proxies correctly
2026-06-22 12:44:45 +00:00
github-actions[bot] 12e03304af chore: bump version to 1.0.57 [skip ci] 2026-06-22 12:32:56 +00:00
myron 1c4a06d31e fix: docker image-remove throws on daemon error; add sync-orphans endpoint
- removeImage now throws RuntimeException when docker rmi output contains
  'Error' or 'conflict' so the API returns success:false with the message
- Added docker/sync-orphans endpoint (admin only) to register existing
  Docker containers not tracked in the NovaCPX DB (e.g. after a restore)
2026-06-22 12:32:45 +00:00
github-actions[bot] 55f5fc1da9 chore: bump version to 1.0.56 [skip ci] 2026-06-22 12:24:20 +00:00
myron b00cf10120 fix: escape apostrophe in FTP empty-state string — caused SyntaxError in template literal 2026-06-22 12:24:07 +00:00
github-actions[bot] e88a5e6fdc chore: bump version to 1.0.55 [skip ci] 2026-06-22 12:21:05 +00:00
myron 76726dc47c feat: #41-#47 admin root controls — enhanced pages + new APIs
#41 phpMyAdmin: quick-access links in database manager
#43 PostgreSQL: Adminer at /adminer.php (MySQL + PostgreSQL)
#44 Mail server: virtual domains list, mail log tail, better service controls
#45 FTP server: full account list from DB, better service controls
#47 Web server: stats cards, PHP defaults, log viewer

New APIs: system/read-log, email/domains
Fix: PHP-FPM pm.max_children increased to 20 (was 5, causing exhaustion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-22 12:20:53 +00:00
github-actions[bot] 8405772e01 chore: bump version to 1.0.54 [skip ci] 2026-06-22 05:11:11 +00:00
myron 6b59730bec fix: user account settings page — use stats/account instead of forbidden accounts/usage+me 2026-06-22 05:11:00 +00:00
github-actions[bot] 9157307ae1 chore: bump version to 1.0.53 [skip ci] 2026-06-22 05:02:31 +00:00
myron e0447bc5f5 sync: admin/index.php sidebar cleanup (subdomains/parked nav onclick attrs removed) 2026-06-22 05:02:20 +00:00
github-actions[bot] b278effdfb chore: bump version to 1.0.52 [skip ci] 2026-06-22 04:52:20 +00:00
myron 697763f333 fix: wrap server_stats INSERT in try/catch — SQLite lock was killing stats API
Concurrent cron writes (collect-stats.php every 5min) caused DB lock errors
that aborted the entire stats response, leaving web/mail/FTP pages empty.
History insert is now non-fatal.
2026-06-22 04:52:08 +00:00
github-actions[bot] fcde84d2ad chore: bump version to 1.0.51 [skip ci] 2026-06-22 04:33:42 +00:00
myron 9caaa65b31 fix: broken adminSubdomains/adminParked JS from bad patch; CORS PORT_* constants 2026-06-22 04:33:32 +00:00
github-actions[bot] fe41a97e74 chore: bump version to 1.0.50 [skip ci] 2026-06-22 04:29:23 +00:00
myron 2ecf93a344 fix: hardcode panel ports in CORS check — PORT_USER etc undefined before Core.php loads
Using PORT_USER ?? 8880 threw Error in PHP 8 since the constant isn't defined
until Core.php is require_once'd later in the file. Every API request was
hitting the exception handler and returning 'An internal error occurred.',
breaking all logins and API calls.
2026-06-22 04:29:15 +00:00
github-actions[bot] a5bb5dfddd chore: bump version to 1.0.49 [skip ci] 2026-06-22 04:22:08 +00:00
myron 6f494e96fd feat: #38 account settings page (user panel); #39 better default index template
#38 — User panel Account > Settings page: account info, resource usage
gauges, PHP config (version/memory/upload/exec), quick links to SSL/2FA/password.

#39 — AccountManager: dark-themed modern default index.html on account
creation; supports custom HTML template from admin Server Options
(saved as default_index_template setting, {domain}/{username} placeholders).
Admin Server Options: new card to set/reset the custom template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-22 04:21:58 +00:00
github-actions[bot] 5a2e81e754 chore: bump version to 1.0.48 [skip ci] 2026-06-22 04:12:49 +00:00
myron 5d1d47a007 feat: #36 subdomains + #37 parked domains sections in all 3 panels
Admin: global view of all subdomains/parked across accounts; nav items added
Reseller: filtered view scoped to their customers' accounts
User: create/remove subdomains and parked domains for own account

Backend already existed in api/endpoints/domains.php (add-subdomain,
add-alias, list, remove actions).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-22 04:12:41 +00:00
github-actions[bot] 6b945bb0fa chore: bump version to 1.0.47 [skip ci] 2026-06-22 04:07:11 +00:00
myron 7b11439f9c feat: #48 collapsible nav in all 3 panels; #50 post-restore automation script
- nova.js: _initCollapsibleNav() exposed as window._initCollapsibleNav
- user.js + reseller.js: call _initCollapsibleNav after renderNav()
- deploy/novacpx-post-restore.sh: fixes config.ini, pools, vhost, dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-22 04:07:00 +00:00
github-actions[bot] 3684f7c6c2 chore: bump version to 1.0.46 [skip ci] 2026-06-21 16:03:35 +00:00
myron 956defc34b fix: all code review security findings
- CORS: replace open regex with explicit hostname allowlist + port whitelist
- Exception handler: only expose RuntimeException/InvalidArgumentException
  messages; PDOException and others return generic 'internal error'
- Auth::portalUrl(): allowlist-validate HTTP_HOST before using it in
  redirect URL — prevents open redirect via Host header injection
- _branding.php custom_css: strip HTML tags, js: URLs, @import, expression()
  instead of just </style> which was trivially bypassable
- accounts create: check accounts table as well as users for username
  uniqueness (TOCTOU fix); wrap user INSERT + provisioning in single
  transaction so rollback is atomic on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-21 16:03:26 +00:00
github-actions[bot] 21dd846508 chore: bump version to 1.0.45 [skip ci] 2026-06-21 03:44:46 +00:00
myron 60004a29d6 fix: default web server to nginx, add php-fpm pool cron, sudoers for pool rm, disable apache on install (#49)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-21 03:44:37 +00:00
github-actions[bot] b281768685 chore: bump version to 1.0.44 [skip ci] 2026-06-20 21:17:16 +00:00
myron 1a907d18b0 feat: collapsible sidebar nav with localStorage state (#48)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 21:17:08 +00:00
github-actions[bot] c0cc88ac3b chore: bump version to 1.0.43 [skip ci] 2026-06-20 21:04:27 +00:00
myron 65a8690750 fix: use /bin/rm explicitly in removePool so sudoers path matches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 21:04:17 +00:00
github-actions[bot] 29e2744609 chore: bump version to 1.0.42 [skip ci] 2026-06-20 17:07:01 +00:00
myron 91d0e625c4 fix: decouple php-fpm reload from HTTP request using flag file + cron
Reload during account creation was causing 502 by killing the fpm worker
before nginx finished reading the response. Flag file picked up by cron
within 60s instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 17:06:52 +00:00
github-actions[bot] 99829f628d chore: bump version to 1.0.41 [skip ci] 2026-06-20 16:44:17 +00:00
myron e12f569460 fix: replace global email UNIQUE with partial index scoped to role=user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 16:44:09 +00:00
github-actions[bot] def6edf4d5 chore: bump version to 1.0.40 [skip ci] 2026-06-20 16:42:24 +00:00
myron 9aa67f7efd fix: email uniqueness check only applies to hosting accounts, not admin/reseller users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 16:42:16 +00:00
github-actions[bot] a8a80bff5e chore: bump version to 1.0.39 [skip ci] 2026-06-20 16:39:57 +00:00
myron eb84504689 fix: remove php-fpm pool on account creation rollback to prevent fpm crash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 16:39:48 +00:00
github-actions[bot] 2d074ea25e chore: bump version to 1.0.38 [skip ci] 2026-06-20 16:35:08 +00:00
myron 8e623427e3 fix: reload php-fpm async to prevent killing the account-creation request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 16:34:59 +00:00
github-actions[bot] 79857a750a chore: bump version to 1.0.37 [skip ci] 2026-06-20 16:09:42 +00:00
myron 3ad7ee44c2 fix: nova.js 401 handler in correct panel/public path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 16:09:33 +00:00
github-actions[bot] 15bbe955fa chore: bump version to 1.0.36 [skip ci] 2026-06-20 15:59:42 +00:00
myron 3dab4ffe0f fix: show real error message on login 401, not misleading 'Session expired'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 15:59:34 +00:00
github-actions[bot] ee7e488f3d chore: bump version to 1.0.35 [skip ci] 2026-06-20 05:46:41 +00:00
myron b534e7e306 fix: nested transaction crash and favicon 404
- accounts.php: remove outer beginTransaction() — AccountManager already wraps in its own transaction; nested transactions fail in SQLite with 'already an active transaction'
- accounts.php: on AccountManager failure, manually delete the inserted user row instead
- admin/reseller/user index.php: fix favicon href from /assets/img/favicon.svg to nova-favicon.svg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 05:46:32 +00:00
github-actions[bot] 0b6850673a chore: bump version to 1.0.34 [skip ci] 2026-06-20 05:40:09 +00:00
myron 6dd2e3a08d fix: add all server-only assets and panel files missing from repo
Previously missing from git (rsync --delete was wiping them on every deploy):
- assets/css/nova.css
- assets/js/nova.js, features.js, reseller.js, user.js
- assets/img/*.svg (favicon, icons, logo, mark)
- index.php, _branding.php, errors/404.php, errors/500.php
- reseller/index.php, user/index.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 05:40:00 +00:00
github-actions[bot] 89c1deea5c chore: bump version to 1.0.33 [skip ci] 2026-06-20 05:33:45 +00:00
myron b077226581 fix: recover full admin.js from server, fix port redirects and account create validation
- admin.js: 1292 lines of features were on server but not in repo — recovered and committed
- admin.js: impersonation redirect now uses location.origin instead of hardcoded :8880 port
- accounts.php: pre-validate email uniqueness and username before INSERT to prevent SQLSTATE 23000
- accounts.php: wrap user INSERT + AccountManager::create() in single transaction for full rollback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 05:33:35 +00:00
github-actions[bot] 7185fcca5f chore: bump version to 1.0.32 [skip ci] 2026-06-20 05:27:44 +00:00
myron 91bf8d965f fix: portalUrl stays on proxy host when accessed via reverse proxy on port 443
When HTTP_HOST has no port (NPM on 443), return URL without appending panel port.
Direct access (HTTP_HOST includes :8882 etc.) still redirects to correct port.
Prevents browser being sent to :8882 directly after login via novacpx.orbishosting.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 05:27:34 +00:00
github-actions[bot] ab7c69d086 chore: bump version to 1.0.31 [skip ci] 2026-06-20 05:23:50 +00:00
myron 39942929a7 fix: global exception handler (prevents 502), transaction rollback on account create, CORS for reverse proxy
- set_exception_handler in api/index.php prevents uncaught exceptions from crashing PHP-FPM
- AccountManager::create() wrapped in DB transaction with rollback + Linux user cleanup on failure
- CORS origin regex updated to allow requests from port 443 (NPM reverse proxy)
- index.html written via sudo tee instead of file_put_contents (www-data permission fix)
- chpasswd now called with sudo prefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 05:23:42 +00:00
github-actions[bot] cbd20c5390 chore: bump version to 1.0.30 [skip ci] 2026-06-20 04:36:37 +00:00
myron 8497aecc8d fix: add missing sudoers permissions and nginx dir ownership for account creation
- useradd/userdel/usermod/chpasswd for hosting account management
- mkdir/chown/chmod for home directory provisioning
- nginx sites-available and sites-enabled write permissions
- certbot, opendkim-genkey, rndc, named-checkzone for SSL and DKIM
- chown root:www-data on nginx vhost dirs so VhostManager can write configs directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-20 04:36:21 +00:00
github-actions[bot] 08844c6f79 chore: bump version to 1.0.29 [skip ci] 2026-06-10 14:45:04 +00:00
myron 5ce5bd1520 fix: reseller creation and management in admin panel
- admin.js was calling auth/register (action does not exist) — changed
  to users/create
- Reseller list was fetching from accounts/list which is for hosting
  accounts; fixed to users/list?role=reseller
- Replaced shared adminSuspend/adminChangePass (account-scoped) with
  dedicated adminResellerSuspend/Unsuspend/Passwd/Delete functions that
  operate on the users table
- Added users endpoint actions: create, suspend, unsuspend,
  change-password, delete — all admin-only, operating on user rows
  rather than hosting account rows
- Reseller delete disowns their accounts (sets reseller_id=NULL) rather
  than cascading delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:44:51 +00:00
github-actions[bot] 87c0f9c651 chore: bump version to 1.0.28 [skip ci] 2026-06-10 14:30:55 +00:00
myron 483ab8a002 fix: copy VERSION to web root on every deploy
deploy-runner.sh was rsyncing panel/public/ but VERSION lives at repo
root — web root /srv/novacpx/public/VERSION was perpetually stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:30:41 +00:00
github-actions[bot] 7e4b9e18b2 chore: bump version to 1.0.27 [skip ci] 2026-06-10 13:35:22 +00:00
myron 008658e0ec feat: expand Docker app catalog from 60 to 140 apps
Added 80 new apps across 13 categories: AI/LLM (Ollama, Open-WebUI,
Flowise, Langfuse, AnythingLLM, LocalAI, ComfyUI), Dev Tools
(code-server, Jenkins, SonarQube, Vault, MailHog, Dozzle, Yacht,
Semaphore), Databases (Redis, MongoDB, PostgreSQL, MariaDB,
Elasticsearch, InfluxDB, Neo4j, Qdrant), Monitoring (Loki+Grafana,
Jaeger, VictoriaMetrics, changedetection.io, Metabase), Networking
(Pi-hole, AdGuard Home, NPM, Traefik, wg-easy, Cloudflared, CrowdSec,
Authelia), CMS/Commerce (Drupal, Joomla, Grav, PrestaShop, OpenCart,
Medusa, Answer), Project Mgmt (Plane, OpenProject, Leantime, WeKan,
Focalboard), Communication (Matrix Synapse, Gotify, ntfy, Grist,
Mailpit), File/Storage (Syncthing, SFTPGo, ownCloud, Duplicati,
Calibre-Web), ERP/Business (Odoo, Akaunting, Monica, Twenty CRM,
Dolibarr), Media (Audiobookshelf, Komga, Grocy, TubeArchivist, AFFiNE),
Smart Home (Home Assistant, Node-RED, Mosquitto, Zigbee2MQTT),
Dashboards (Dashy, Homarr, Homer, IT-Tools, CloudBeaver, pgAdmin,
phpMyAdmin, Infisical).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:34:31 +00:00
github-actions[bot] db033732b4 chore: bump version to 1.0.26 [skip ci] 2026-06-10 13:01:12 +00:00
myron 9bc427f8a2 feat: per-stack Reinstall + fix stack ownership enforcement
- API: stack-action/stack-remove now verify ownership for non-admin users
- API: add stack-reinstall action (pull latest images → down → up)
- User panel: add Reinstall button per stack; fix bug where remove-stack was called instead of stack-remove
- Admin panel: add Reinstall button per stack + dockerStackReinstall() handler
- User panel: Remove All My Apps now only removes the calling user's own containers/stacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:01:01 +00:00
github-actions[bot] f17210fd3b chore: bump version to 1.0.25 [skip ci] 2026-06-10 12:58:54 +00:00
myron 7a42be8d01 feat: Docker catalog in admin panel + per-account app removal
- Admin Docker page: add App Catalog tab (60 apps, account-picker modal)
- Admin Docker page: add dockerAdminLaunchApp() for launching apps on behalf of any account
- User panel: add 'Remove All My Apps' button — stops/removes only that user's own containers and stacks
- API: add uninstall-account action (user-scoped; admin can specify account_id, users limited to own account)
- Admin panel: no global Docker uninstall (would affect all users on the server)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:58:43 +00:00
github-actions[bot] 2e96c2b0b9 chore: bump version to 1.0.24 [skip ci] 2026-06-10 12:55:47 +00:00
myron 61329ff343 feat: expand Docker app catalog from 21 to 60 apps
Adds 39 new apps across 8 categories:
- Media & Files: Jellyfin, Navidrome, Kavita, Paperless-ngx, File Browser, Seafile, Immich
- Monitoring & DevOps: Adminer, Grafana, Prometheus, Netdata, Glances, Healthchecks, Docker Registry, Verdaccio, Watchtower
- Git & CI/CD: Forgejo, Woodpecker CI
- Knowledge: BookStack, HedgeDoc, FreshRSS, Wallabag, Homepage
- Auth & Security: Keycloak, Authentik, Passbolt CE
- Analytics: Plausible (Postgres + ClickHouse)
- Low-code / No-code: Baserow, Appsmith CE, NocoDB
- Communication: Rocket.Chat, Chatwoot, Zammad
- Business: Invoice Ninja, Linkding, Mealie
- Design: Penpot, Excalidraw, Stirling PDF

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:55:35 +00:00
github-actions[bot] 81a8ea88f4 chore: bump version to 1.0.23 [skip ci] 2026-06-10 12:45:41 +00:00
myron 2cc219f9fa feat: add 12 Docker app catalog entries
Adds Uptime Kuma, Portainer CE, MinIO, n8n, Directus, Listmonk, Umami,
PhotoPrism, Meilisearch, Wiki.js, Vikunja, and Mattermost — each with
catalog metadata and a complete docker-compose template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:45:30 +00:00
github-actions[bot] e87e1a14b8 chore: bump version to 1.0.22 [skip ci] 2026-06-10 12:32:17 +00:00
40 changed files with 8447 additions and 378 deletions
+2
View File
@@ -8,3 +8,5 @@ VERSION
node_modules/
.DS_Store
Thumbs.db
panel/public/adminer.php
panel/public/adminer.php
+1 -1
View File
@@ -1 +1 @@
1.0.21
1.0.72
@@ -0,0 +1,30 @@
-- Migration: replace global email UNIQUE with partial index (hosting accounts only)
-- Allows admin/reseller emails to be reused for hosting accounts
CREATE TABLE IF NOT EXISTS users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','reseller','user')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended','pending')),
reseller_id INTEGER DEFAULT NULL,
package_id INTEGER DEFAULT NULL,
theme TEXT DEFAULT 'nova-dark',
language TEXT DEFAULT 'en',
contact_name TEXT,
contact_phone TEXT,
last_login TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT,
totp_secret TEXT,
totp_enabled INTEGER DEFAULT 0,
totp_backup_codes TEXT,
FOREIGN KEY (reseller_id) REFERENCES users(id) ON DELETE SET NULL
);
INSERT OR IGNORE INTO users_new SELECT id,username,password,email,role,status,reseller_id,package_id,theme,language,contact_name,contact_phone,last_login,created_at,updated_at,totp_secret,totp_enabled,totp_backup_codes FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
CREATE INDEX IF NOT EXISTS idx_users_status ON users (status);
CREATE INDEX IF NOT EXISTS idx_users_reseller ON users (reseller_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_user ON users (email) WHERE role = 'user';
+3
View File
@@ -79,6 +79,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT QUEUED_BRANCH; do
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
# Copy VERSION to web root so /VERSION endpoint stays current
[[ -f "$REPO_PATH/VERSION" ]] && cp "$REPO_PATH/VERSION" "$WEB_ROOT/VERSION"
# Run pending DB migrations (SQLite)
MIGR_DIR="$REPO_PATH/db/migrations"
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
# NovaCPX Post-Restore Script v2
# Run after any PBS/backup restore
# Usage: /usr/local/bin/novacpx-post-restore [--no-git]
set -euo pipefail
LOG="/var/log/novacpx/post-restore.log"
PAT=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('deploy','github_pat',fallback=''))" 2>/dev/null || echo "")
WEB_DASHBOARD_REPO="https://${PAT}@github.com/myronblair/web-dashboard.git"
NOVACPX_REPO="https://${PAT}@github.com/myronblair/novacpx.git"
SKIP_GIT="${1:-}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
ok() { log "$*"; }
warn() { log "$*"; }
mkdir -p /var/log/novacpx
log "=== NovaCPX Post-Restore Started ==="
# ── 1. Fix config.ini ─────────────────────────────────────────────────────
log "1. Fixing config.ini..."
sed -i 's/^server = apache/server = nginx/' /etc/novacpx/config.ini
ok "server = $(grep '^server' /etc/novacpx/config.ini | cut -d= -f2 | tr -d ' ')"
# Disable Apache2 if running
systemctl stop apache2 2>/dev/null && systemctl disable apache2 2>/dev/null || true
# ── 2. Fix PHP-FPM ────────────────────────────────────────────────────────
log "2. Fixing PHP-FPM..."
REMOVED=0
for f in /etc/php/8.3/fpm/pool.d/*.conf; do
[[ "$f" == *"www.conf"* ]] && continue
u=$(basename "$f" .conf)
id "$u" &>/dev/null || { rm -f "$f"; ((REMOVED++)) || true; }
done
[[ $REMOVED -gt 0 ]] && warn "Removed $REMOVED orphaned pools" || ok "No orphaned pools"
# Fix pm.max_children if still at default 5
PM=$(grep "^pm.max_children" /etc/php/8.3/fpm/pool.d/www.conf 2>/dev/null | awk '{print $3}')
if [[ "$PM" == "5" ]] || [[ -z "$PM" ]]; then
echo -e "\npm = dynamic\npm.max_children = 20\npm.start_servers = 5\npm.min_spare_servers = 3\npm.max_spare_servers = 10" >> /etc/php/8.3/fpm/pool.d/www.conf
ok "PHP-FPM max_children bumped to 20"
fi
systemctl start php8.3-fpm 2>/dev/null || systemctl restart php8.3-fpm
ok "PHP-FPM $(systemctl is-active php8.3-fpm)"
# ── 3. Pull latest NovaCPX code ───────────────────────────────────────────
if [[ "$SKIP_GIT" != "--no-git" ]] && [[ -n "$PAT" ]]; then
log "3. Pulling latest NovaCPX code..."
cd /opt/novacpx-src 2>/dev/null || { warn "novacpx-src not found, skipping"; true; }
if [[ -d /opt/novacpx-src/.git ]]; then
git remote set-url origin "$NOVACPX_REPO" 2>/dev/null
# Capture exit code separately — pipeline masks it via while loop
GIT_OUT=$(git pull origin main 2>&1) && GIT_OK=1 || GIT_OK=0
echo "$GIT_OUT" | tail -3 | while read l; do log " $l"; done
[[ "$GIT_OK" == "0" ]] && { warn "git pull failed — deploying existing local code"; }
rsync -a --delete --exclude=".git" --exclude="api/config.php" \
/opt/novacpx-src/panel/public/ /srv/novacpx/public/ 2>/dev/null
rsync -a --delete --exclude="config.php" \
/opt/novacpx-src/panel/api/ /srv/novacpx/public/api/ 2>/dev/null
rsync -a --delete /opt/novacpx-src/panel/lib/ /srv/novacpx/public/lib/ 2>/dev/null
ok "Code deployed"
# Run migrations
DB=/var/lib/novacpx/panel.db
for SQL in /opt/novacpx-src/db/migrations/*.sql; do
[[ -f "$SQL" ]] || continue
NAME=$(basename "$SQL" .sql)
DONE=$(sqlite3 "$DB" "SELECT value FROM settings WHERE key='migration_$NAME'" 2>/dev/null)
if [[ -z "$DONE" ]]; then
sqlite3 "$DB" < "$SQL" 2>/dev/null && \
sqlite3 "$DB" "INSERT OR REPLACE INTO settings (key,value,updated_at) VALUES ('migration_$NAME','$(date)',datetime('now'))" 2>/dev/null
ok "Migration: $NAME"
fi
done
fi
else
log "3. Skipping git pull"
fi
# ── 4. Fix webacct hosting account ────────────────────────────────────────
log "4. Fixing webacct hosting account..."
DB=/var/lib/novacpx/panel.db
# Write the PHP helper ONCE here, before any call to it
cat > /tmp/_nova_create_webacct.php << 'PHPEOF'
<?php
define('NOVACPX_ROOT', '/srv/novacpx/public');
define('NOVACPX_API', NOVACPX_ROOT.'/api');
define('NOVACPX_LIB', NOVACPX_ROOT.'/lib');
$_SERVER['SERVER_PORT'] = 8882;
require_once NOVACPX_LIB.'/Core.php';
require_once NOVACPX_LIB.'/DB.php';
require_once NOVACPX_LIB.'/VhostManager.php';
require_once NOVACPX_LIB.'/DNSManager.php';
require_once NOVACPX_LIB.'/PHPManager.php';
require_once NOVACPX_LIB.'/AccountManager.php';
$db = DB::getInstance();
// Clean orphaned user
$db->execute("DELETE FROM users WHERE username='webacct' AND role='user'");
$uid = (int)$db->insert(
"INSERT INTO users (username,password,email,role,status) VALUES (?,?,?,?,?)",
['webacct',password_hash('Joker1974!!!',PASSWORD_BCRYPT),'webacct@web.orbishosting.com','user','active']
);
$r = AccountManager::create(['username'=>'webacct','domain'=>'web.orbishosting.com','password'=>'Joker1974!!!','user_id'=>$uid,'php_version'=>'8.3']);
echo "Created: ".json_encode($r)."\n";
PHPEOF
# Clean orphaned user record (Linux user missing but DB record exists)
if id "webacct" &>/dev/null; then
ok "webacct Linux user exists"
else
warn "webacct Linux user missing — cleaning DB and recreating..."
sqlite3 "$DB" "DELETE FROM users WHERE username='webacct' AND id NOT IN (SELECT user_id FROM accounts WHERE username='webacct');" 2>/dev/null || true
sqlite3 "$DB" "DELETE FROM users WHERE username='webacct' AND role='user';" 2>/dev/null || true
php8.3 /tmp/_nova_create_webacct.php >> "$LOG" 2>&1 || true
fi
# Ensure account exists in DB
ACCT_EXISTS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM accounts WHERE username='webacct';" 2>/dev/null || echo "0")
if [[ "$ACCT_EXISTS" == "0" ]]; then
warn "webacct account not in DB — creating..."
php8.3 /tmp/_nova_create_webacct.php >> "$LOG" 2>&1 && ok "Account created" || warn "Account creation failed — check log"
fi
# ── 5. Ensure nginx vhost and Basic Auth ──────────────────────────────────
log "5. Ensuring nginx vhost..."
VHOST="/etc/nginx/sites-available/novacpx-webacct.conf"
if [[ ! -f "$VHOST" ]]; then
cat > "$VHOST" << 'NGINX'
server {
listen 80;
server_name web.orbishosting.com www.web.orbishosting.com;
root /home/webacct/public_html;
index index.php index.html index.htm;
access_log /home/webacct/logs/access.log;
error_log /home/webacct/logs/error.log;
auth_basic "Blair HQ";
auth_basic_user_file /etc/nginx/htpasswd.webacct;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm-webacct.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht { deny all; }
}
NGINX
ln -sf "$VHOST" /etc/nginx/sites-enabled/novacpx-webacct.conf
ok "Vhost created"
fi
[[ -f /etc/nginx/htpasswd.webacct ]] || {
htpasswd -cb /etc/nginx/htpasswd.webacct "myronblair@outlook.com" "Joker1974!!!" 2>/dev/null
ok "Basic auth created"
}
# ── 6. Deploy Blair HQ dashboard ──────────────────────────────────────────
log "6. Deploying Blair HQ dashboard..."
if [[ -n "$PAT" ]]; then
TMP=$(mktemp -d)
git clone --depth=1 "$WEB_DASHBOARD_REPO" "$TMP" 2>/dev/null && {
cp "$TMP/index.html" /home/webacct/public_html/index.html 2>/dev/null
cp "$TMP/notes.php" /home/webacct/public_html/notes.php 2>/dev/null
chown webacct:www-data /home/webacct/public_html/index.html /home/webacct/public_html/notes.php 2>/dev/null
[[ -f /home/webacct/notes.json ]] || { echo "[]" > /home/webacct/notes.json; chown webacct:www-data /home/webacct/notes.json; }
ok "Dashboard deployed"
} || warn "Dashboard deploy failed (check PAT)"
rm -rf "$TMP"
fi
# ── 7. Reload nginx ───────────────────────────────────────────────────────
log "7. Reloading nginx..."
nginx -t 2>/dev/null && systemctl reload nginx && ok "nginx reloaded" || warn "nginx config error"
systemctl reload php8.3-fpm 2>/dev/null || true
log "=== Post-Restore Complete ==="
echo ""
echo "✓ NovaCPX post-restore complete. Log: $LOG"
+48 -1
View File
@@ -30,7 +30,7 @@ info() { echo -e "${BLUE}[→]${NC} $*" | tee -a "$LOG"; }
step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}" | tee -a "$LOG"; }
# ── Argument parsing ──────────────────────────────────────────────────────────
WEB_SERVER="apache"
WEB_SERVER="nginx"
INSTALL_MYSQL=true
INSTALL_POSTGRES=true
@@ -225,6 +225,9 @@ server {
}
NGXCONF
ln -sf "$PANEL_WEB_CONF" /etc/nginx/sites-enabled/novacpx
# Allow www-data to manage customer vhost configs
chown root:www-data /etc/nginx/sites-available /etc/nginx/sites-enabled
chmod 775 /etc/nginx/sites-available /etc/nginx/sites-enabled
else
apt-get install -y -qq apache2 libapache2-mod-fcgid >> "$LOG" 2>&1
@@ -719,9 +722,30 @@ www-data ALL=(root) NOPASSWD: /bin/systemctl reload php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl restart php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl start php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl stop php*-fpm
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/php/*/fpm/pool.d/*
www-data ALL=(root) NOPASSWD: /bin/rm -f /etc/php/*/fpm/pool.d/*.conf
www-data ALL=(root) NOPASSWD: /usr/bin/rm -f /etc/php/*/fpm/pool.d/*.conf
# Web config file management (scoped paths only)
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/conf.d/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-enabled/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apache2/conf-enabled/*
www-data ALL=(root) NOPASSWD: /bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/*
www-data ALL=(root) NOPASSWD: /bin/rm /etc/nginx/sites-available/novacpx-*
www-data ALL=(root) NOPASSWD: /bin/rm /etc/nginx/sites-enabled/novacpx-*
# Account management (user creation and home directories)
www-data ALL=(root) NOPASSWD: /usr/sbin/useradd
www-data ALL=(root) NOPASSWD: /usr/sbin/userdel
www-data ALL=(root) NOPASSWD: /usr/sbin/usermod
www-data ALL=(root) NOPASSWD: /usr/sbin/chpasswd
www-data ALL=(root) NOPASSWD: /bin/mkdir
www-data ALL=(root) NOPASSWD: /bin/chown
www-data ALL=(root) NOPASSWD: /bin/chmod
# SSL and DKIM
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
www-data ALL=(root) NOPASSWD: /usr/bin/opendkim-genkey
www-data ALL=(root) NOPASSWD: /usr/sbin/rndc reload
www-data ALL=(root) NOPASSWD: /usr/sbin/named-checkzone *
SUDOERS
chmod 440 /etc/sudoers.d/novacpx-firewall
log "Sudoers rules installed"
@@ -736,9 +760,32 @@ cat > /etc/cron.d/novacpx <<CRON
0 2 * * * root /usr/local/bin/novacpx-backup >> /var/log/novacpx/backup.log 2>&1
*/1 * * * * root /usr/local/bin/novacpx-dns-sync >> /var/log/novacpx/dns.log 2>&1
CRON
# PHP-FPM pool cleanup + deferred reload (runs every minute as root)
# Removes orphaned pool configs for deleted Linux users before reloading,
# preventing php-fpm from failing to start due to missing user references.
(crontab -l 2>/dev/null | grep -v "novacpx-fpm-reload"; echo '* * * * * for f in /etc/php/*/fpm/pool.d/*.conf; do [[ "$f" == *"www.conf"* ]] && continue; u=$(basename "$f" .conf); id "$u" &>/dev/null || rm -f "$f"; done; for flag in /tmp/novacpx-fpm-reload-*; do [ -f "$flag" ] && ver=$(basename "$flag" | sed s/novacpx-fpm-reload-//) && rm -f "$flag" && systemctl reload php${ver}-fpm 2>/dev/null; done') | crontab -
mkdir -p /var/log/novacpx
log "Cron jobs installed"
# ── Disable conflicting web servers ───────────────────────────────────────────
step "Disabling Conflicting Web Servers"
if [[ "$WEB_SERVER" == "nginx" ]]; then
systemctl stop apache2 2>/dev/null || true
systemctl disable apache2 2>/dev/null || true
# Replace nginx default site with a 444 connection-close so unmatched
# vhosts don't accidentally serve Apache's default HTML page
cat > /etc/nginx/sites-available/default <<'NGINXDEFAULT'
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
NGINXDEFAULT
log "Apache2 disabled; nginx default site set to return 444"
fi
# ── Restart services ──────────────────────────────────────────────────────────
step "Starting All Services"
if [[ "$WEB_SERVER" == "nginx" ]]; then
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* Server-side branding loader — injected into portal <head> before JS loads.
* Reads session cookie → looks up user's reseller → returns branding row.
*/
function novacpx_get_branding(): array {
static $cache = null;
if ($cache !== null) return $cache;
$cfg = @parse_ini_file('/etc/novacpx/config.ini', true);
if (!$cfg) return $cache = [];
try {
$dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db';
$pdo = new PDO(
"sqlite:{$dbPath}", null, null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$token = $_COOKIE['ncpx_session'] ?? '';
if (!$token || strlen($token) < 32) return $cache = [];
$stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now') LIMIT 1");
$stmt->execute([substr($token, 0, 128)]);
$uid = (int)($stmt->fetchColumn() ?: 0);
if (!$uid) return $cache = [];
$stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1");
$stmt->execute([$uid]);
$u = $stmt->fetch();
if (!$u) return $cache = [];
$resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0);
if (!$resellerId) return $cache = [];
$stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1");
$stmt->execute([$resellerId]);
$row = $stmt->fetch();
return $cache = ($row ?: []);
} catch (Throwable $e) {
return $cache = [];
}
}
function novacpx_branding_head(): void {
$b = novacpx_get_branding();
if (!$b) return;
$pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null;
$ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null;
$css = $b['custom_css'] ?? '';
echo '<style id="reseller-branding">' . "\n";
echo ':root {' . "\n";
if ($pc) echo " --primary: $pc;\n --primary-dark: $pc;\n";
if ($ac) echo " --accent: $ac;\n";
echo '}' . "\n";
// Sanitize custom CSS — allow only safe property declarations, strip everything else.
// Regex approach (strip </style>) is bypassable; whitelist parsing is the safe alternative.
$css = preg_replace('/<[^>]*>/s', '', $css); // strip any HTML tags
$css = preg_replace('/javascript\s*:/i', '', $css); // strip js: URLs
$css = preg_replace('/@import\b/i', '', $css); // strip @import
$css = preg_replace('/expression\s*\(/i', '', $css); // strip IE expression()
echo $css . "\n";
echo '</style>' . "\n";
if ($b['favicon_url'] ?? '') {
$fav = htmlspecialchars($b['favicon_url']);
echo "<link rel=\"icon\" href=\"$fav\">\n";
}
}
function novacpx_panel_name(string $default): string {
$b = novacpx_get_branding();
return htmlspecialchars($b['panel_name'] ?? $default);
}
function novacpx_logo_html(string $default_svg): string {
$b = novacpx_get_branding();
if (!empty($b['logo_url'])) {
$url = htmlspecialchars($b['logo_url']);
$name = htmlspecialchars($b['panel_name'] ?? 'Panel');
return "<img src=\"$url\" alt=\"$name\" style=\"max-height:36px;max-width:160px;object-fit:contain\">";
}
return $default_svg;
}
function novacpx_powered_by(): bool {
$b = novacpx_get_branding();
return empty($b['hide_powered_by']);
}
+42 -20
View File
@@ -1,33 +1,34 @@
<?php
// NovaCPX Admin Panel — Datacenter/Server Manager
// Equivalent to WHM (WebHost Manager)
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
require_once dirname(__DIR__) . '/_branding.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="NovaCPX Admin Panel — full-featured hosting control panel for managing servers, accounts, domains, email, databases, and services.">
<meta name="keywords" content="hosting control panel, server management, web hosting admin, NovaCPX, domain management, email hosting, database management">
<meta name="robots" content="noindex, nofollow">
<title>NovaCPX Admin</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
</head>
<body>
<div class="panel-layout" id="app" style="display:none">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- 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>
<?= novacpx_logo_html('<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 id="panel-version-badge" style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: NOVACPX_VERSION) ?></span>
</span>
</div>
<nav>
@@ -37,10 +38,6 @@
<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">
@@ -97,10 +94,18 @@
<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>
<a href="#" class="sidebar-link" data-page="nginx-proxy">
<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>
Nginx Proxy
</a>
<a href="#" class="sidebar-link" data-page="wordpress">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 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>
WordPress
</a>
<a href="#" class="sidebar-link" data-page="docker">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="8" width="4" height="4" rx="1"/><rect x="8" y="8" width="4" height="4" rx="1"/><rect x="14" y="8" width="4" height="4" rx="1"/><rect x="8" y="2" width="4" height="4" rx="1"/><path d="M2 14c0 4 3 6 10 6s10-2 10-6"/><path d="M20 14c1.5 0 2.5-1 2.5-2.5S21.5 9 20 9h-1"/></svg>
Docker
</a>
</div>
<div class="sidebar-section">
@@ -111,7 +116,11 @@
</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
Firewall (UFW)
</a>
<a href="#" class="sidebar-link" data-page="fail2ban">
<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"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
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>
@@ -121,6 +130,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
2FA Manager
</a>
<a href="#" class="sidebar-link" data-page="sessions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
Sessions
</a>
</div>
<div class="sidebar-section">
@@ -137,6 +150,14 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
Cloudflare
</a>
<a href="#" class="sidebar-link" data-page="server-options">
<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 Options
</a>
<a href="#" class="sidebar-link" data-page="notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
Notifications
</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
@@ -161,7 +182,8 @@
<!-- Main Content -->
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions">
<span id="server-ip" class="text-muted text-sm"></span>
@@ -206,7 +228,7 @@
</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/admin.js<?= $_v('/assets/js/admin.js') ?>"></script>
</body>
</html>
+16 -3
View File
@@ -69,7 +69,14 @@ match ($action) {
$required = ['username','domain','email','password'];
foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); }
// Create user account
if (!filter_var($body['email'], FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address");
if ($db->fetchOne("SELECT id FROM users WHERE email = ? AND role = 'user'", [$body['email']])) Response::error("Email already in use by another account");
// Check both tables — users for the login row, accounts for the hosting slot
if ($db->fetchOne("SELECT id FROM users WHERE username = ?", [$body['username']])) Response::error("Username already taken");
if ($db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$body['username']])) Response::error("Username already taken");
// Wrap user insert + provisioning in a transaction so cleanup is atomic
$db->beginTransaction();
$userId = (int)$db->insert(
"INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)",
[
@@ -83,9 +90,15 @@ match ($action) {
);
$body['user_id'] = $userId;
$result = AccountManager::create($body);
try {
$result = AccountManager::create($body);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
audit('account.create', $body['domain'], $result);
// Send welcome email to user + admin notification
Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']);
Response::success($result, 'Account created successfully');
})(),
+79 -4
View File
@@ -29,6 +29,36 @@ match ($action) {
Response::success(null, $msg);
})(),
'uninstall-account' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId, $body) {
// Stop and remove all containers and stacks belonging to one account.
// Users can only remove their own; admins can specify any account_id.
$accountId = $isAdmin ? (int)($body['account_id'] ?? $_userAccountId) : ($_userAccountId ?? 0);
if (!$accountId) Response::error('account_id required');
$db = DB::getInstance();
// Tear down compose stacks
$stacks = $db->fetchAll("SELECT * FROM docker_compose_stacks WHERE account_id = ?", [$accountId]);
foreach ($stacks as $stack) {
if (is_dir($stack['stack_dir']) && file_exists("{$stack['stack_dir']}/docker-compose.yml")) {
shell_exec("sudo docker compose -f " . escapeshellarg("{$stack['stack_dir']}/docker-compose.yml") . " down -v 2>/dev/null");
}
$db->execute("DELETE FROM docker_compose_stacks WHERE id = ?", [$stack['id']]);
}
// Stop and remove bare containers
$containers = $db->fetchAll("SELECT container_id FROM docker_containers WHERE account_id = ?", [$accountId]);
foreach ($containers as $c) {
if ($c['container_id']) {
shell_exec("sudo docker rm -f " . escapeshellarg($c['container_id']) . " 2>/dev/null");
}
}
$db->execute("DELETE FROM docker_containers WHERE account_id = ?", [$accountId]);
audit("docker.uninstall-account", "account:{$accountId}");
Response::success(null, 'All Docker apps and containers removed for this account');
})(),
'prune' => (function() use ($dm, $body, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403);
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
@@ -163,20 +193,42 @@ match ($action) {
Response::success($result, 'Stack created');
})(),
'stack-action' => (function() use ($dm, $body, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403);
'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
$id = (int)($body['stack_id'] ?? 0);
$act = $body['action'] ?? '';
if (!$id || !$act) Response::error('stack_id and action required');
// Non-admins can only act on their own stacks
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
$out = $dm->composeAction($id, $act);
audit("docker.stack.{$act}", "stack:{$id}");
Response::success(['output' => $out]);
})(),
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403);
'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
$id = (int)($body['stack_id'] ?? 0);
if (!$id) Response::error('stack_id required');
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
// Pull latest images, bring down (removing volumes), then start fresh
$dm->composeAction($id, 'pull');
$dm->composeAction($id, 'down');
$out = $dm->composeAction($id, 'up');
audit('docker.stack.reinstall', "stack:{$id}");
Response::success(['output' => $out], 'Stack reinstalled');
})(),
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
$id = (int)($body['stack_id'] ?? 0);
if (!$id) Response::error('stack_id required');
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
$dm->removeStack($id);
audit('docker.stack.remove', "stack:{$id}");
Response::success(null, 'Stack removed');
@@ -213,5 +265,28 @@ match ($action) {
Response::success($result, ucfirst($appKey) . ' launched successfully');
})(),
'sync-orphans' => (function() use ($dm, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403);
$result = shell_exec('docker ps -a --format "{{json .}}" 2>/dev/null') ?? '';
$db = \DB::getInstance();
$added = 0;
foreach (explode("\n", trim($result)) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if (!$c) continue;
$cid = $c['ID'] ?? '';
$name = ltrim($c['Names'] ?? '', '/');
$img = $c['Image'] ?? '';
$st = $c['State'] ?? 'unknown';
if (!$cid) continue;
$ex = $db->fetchOne('SELECT id FROM docker_containers WHERE container_id=?', [$cid]);
if (!$ex) {
$db->execute('INSERT INTO docker_containers (container_id,name,image,status,account_id,created_at) VALUES (?,?,?,?,0,datetime("now"))', [$cid,$name,$img,$st]);
$added++;
}
}
Response::success(['added' => $added], "Synced $added orphaned containers");
})(),
default => Response::error("Unknown docker action: $action", 404),
};
+16
View File
@@ -98,5 +98,21 @@ match ($action) {
Response::success(null, 'Autoresponder deleted');
})(),
'domains' => (function() use ($db) {
Auth::getInstance()->require('admin', 'reseller');
$user = Auth::getInstance()->user();
$clause = $user['role'] === 'reseller' ? "AND a.user_id IN (SELECT id FROM users WHERE reseller_id=".(int)$user['uid'].")" : "";
$rows = $db->fetchAll(
"SELECT d.domain, a.username, a.id as account_id,
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count
FROM dns_zones d
JOIN accounts a ON a.id = d.account_id
WHERE 1=1 $clause
ORDER BY d.domain"
);
Response::success($rows);
})(),
default => Response::error("Unknown email action: $action", 404),
};
+4 -3
View File
@@ -50,9 +50,10 @@ match ($action) {
"UPDATE packages SET name=?, disk_mb=?, bandwidth_mb=?, max_domains=?, max_subdomains=?,
max_addon_domains=?, max_parked_domains=?, max_email=?, max_ftp=?, max_databases=?, php_version=?, ssl_enabled=? WHERE id=?",
[
$body['name'], $body['disk_mb'], $body['bandwidth_mb'], $body['max_domains'],
$body['max_subdomains'], $body['max_addon_domains'], $body['max_parked_domains'],
$body['max_email'], $body['max_ftp'], $body['max_databases'],
$body['name'] ?? '', $body['disk_mb'] ?? 0, $body['bandwidth_mb'] ?? 0,
$body['max_domains'] ?? 0, $body['max_subdomains'] ?? 0,
$body['max_addon_domains'] ?? 0, $body['max_parked_domains'] ?? 0,
$body['max_email'] ?? 0, $body['max_ftp'] ?? 0, $body['max_databases'] ?? 0,
$body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id,
]
);
+46 -9
View File
@@ -390,11 +390,13 @@ BASH;
if ($active) $services[$svc] = $active;
}
// Persist to DB for history
$db->execute(
"INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)",
[$cpuPct, $ramPct, $diskPct, $load[0]]
);
// Persist to DB for history (non-fatal — don't let lock errors kill the stats response)
try {
$db->execute(
"INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)",
[$cpuPct, $ramPct, $diskPct, $load[0]]
);
} catch (Throwable $_) {}
Response::success([
'cpu' => ['pct' => $cpuPct, 'load' => $load],
@@ -922,6 +924,8 @@ BASH;
|| is_dir('/usr/share/phpmyadmin');
$pgaInstalled = (int)trim(shell_exec("dpkg -l pgadmin4 2>/dev/null | grep -c '^ii'") ?: '0') > 0
|| is_file('/usr/pgadmin4/bin/pgadmin4') || is_dir('/usr/pgadmin4');
$adminerInstalled = is_file(NOVACPX_ROOT . '/adminer.php');
$adminerVer = $adminerInstalled ? 'bundled' : '';
$pmaVer = $pmaInstalled
? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '')
: '';
@@ -929,8 +933,9 @@ BASH;
? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '')
: '';
Response::success([
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer],
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer, 'url' => '/phpmyadmin'],
'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer],
'adminer' => ['installed' => $adminerInstalled, 'version' => $adminerVer, 'url' => '/adminer.php'],
]);
})(),
@@ -938,7 +943,7 @@ BASH;
Auth::getInstance()->require('admin');
$tool = $body['tool'] ?? '';
$action = $body['action'] ?? '';
if (!in_array($tool, ['phpmyadmin','pgadmin'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; }
if (!in_array($tool, ['phpmyadmin','pgadmin','adminer'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; }
if (!in_array($action, ['install','reinstall','remove'])) { echo 'data:'.json_encode(['error'=>'Invalid action'])."\n\n"; exit; }
header('Content-Type: text/event-stream');
@@ -970,7 +975,22 @@ BASH;
$env = 'sudo env DEBIAN_FRONTEND=noninteractive';
if ($tool === 'phpmyadmin') {
if ($tool === 'adminer') {
$adminerPath = NOVACPX_ROOT . '/adminer.php';
if ($action === 'remove') {
$sse("▶ Removing Adminer…\n");
$run('sudo rm -f ' . escapeshellarg($adminerPath));
$sse(" ✓ Removed!\n");
} else {
$sse("▶ Downloading Adminer…\n");
$out = shell_exec('curl -sL https://www.adminer.org/latest.php -o ' . escapeshellarg($adminerPath) . ' 2>&1');
if (is_file($adminerPath) && filesize($adminerPath) > 100000) {
$sse(" ✓ Adminer installed at /adminer.php (" . round(filesize($adminerPath)/1024) . " KB)\n");
} else {
$sse(" ✗ Download failed: $out\n");
}
}
} else if ($tool === 'phpmyadmin') {
if ($action === 'remove') {
$sse("▶ Removing phpMyAdmin…\n");
$run("$env apt-get remove -y phpmyadmin 2>&1");
@@ -1055,5 +1075,22 @@ BASH;
exit;
})(),
default => Response::error("Unknown system action: $action", 404),
'read-log' => (function() {
Auth::getInstance()->require('admin');
$log = preg_replace('/[^a-z0-9-]/', '', $_GET['log'] ?? 'panel');
$map = [
'panel' => '/var/log/novacpx/panel.log',
'deploy' => '/var/log/novacpx/deploy.log',
'nginx-error' => '/var/log/novacpx/nginx-error.log',
'nginx-access' => '/var/log/novacpx/nginx-access.log',
'mail' => '/var/log/mail.log',
'stats' => '/var/log/novacpx/stats-collector.log',
];
$path = $map[$log] ?? '/var/log/novacpx/panel.log';
$raw = file_exists($path) ? trim(shell_exec('tail -100 ' . escapeshellarg($path)) ?: '') : '';
Response::success(['content' => $raw, 'log' => $log]);
})(),
default => Response::error("Unknown system action: $action", 404),
};
+75
View File
@@ -20,5 +20,80 @@ match ($action) {
Response::success($rows);
})(),
// Create a panel user (reseller or admin) — no hosting account provisioned
'create' => (function() use ($db, $body) {
$username = trim($body['username'] ?? '');
$password = $body['password'] ?? '';
$email = trim($body['email'] ?? '');
$role = $body['role'] ?? 'reseller';
if (!$username || !$password || !$email) Response::error('username, password and email are required');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address');
if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin');
if (strlen($password) < 8) Response::error('Password must be at least 8 characters');
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .');
$exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]);
if ($exists) Response::error('Username or email already in use');
$uid = (int)$db->insert(
"INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))",
[$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active']
);
audit("user.create.{$role}", "user:{$username}");
Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created');
})(),
// Suspend a panel user (sets status=suspended on the users row)
'suspend' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
if ($u['role'] === 'admin') Response::error('Cannot suspend admin users');
$db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]);
audit('user.suspend', "user:{$u['username']}");
Response::success(null, 'User suspended');
})(),
// Unsuspend a panel user
'unsuspend' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
$db->execute("UPDATE users SET status='active' WHERE id=?", [$id]);
audit('user.unsuspend', "user:{$u['username']}");
Response::success(null, 'User unsuspended');
})(),
// Change password for a panel user by user id
'change-password' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (!$id) Response::error('id required');
if (strlen($pass) < 8) Response::error('Password must be at least 8 characters');
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
$db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]);
audit('user.change-password', "user:{$u['username']}");
Response::success(null, 'Password updated');
})(),
// Delete a panel user (admin or reseller — not a hosting account user)
'delete' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
if (!$user) Response::error('User not found', 404);
if ($user['role'] === 'admin') Response::error('Cannot delete admin users');
// Disown accounts under this reseller rather than deleting them
$db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]);
$db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]);
$db->execute("DELETE FROM users WHERE id=?", [$id]);
audit('user.delete', "user:{$user['username']}");
Response::success(null, 'User deleted');
})(),
default => Response::error("Unknown users action: $action", 404),
};
+23 -2
View File
@@ -9,14 +9,35 @@ define('NOVACPX_API', __DIR__);
define('NOVACPX_LIB', NOVACPX_ROOT . '/lib');
header('Content-Type: application/json');
// Global exception handler — prevents uncaught exceptions from crashing PHP-FPM (502)
set_exception_handler(function (Throwable $e) {
http_response_code(500);
// Never expose internal exception messages (may contain SQL, paths, credentials)
$safe = ($e instanceof \InvalidArgumentException || $e instanceof \RuntimeException)
? $e->getMessage()
: 'An internal error occurred.';
echo json_encode(['success' => false, 'message' => $safe, 'errors' => []]);
exit;
});
$_ver = file_get_contents(NOVACPX_ROOT . '/VERSION')
?: file_get_contents('/opt/novacpx-src/VERSION')
?: '1.0.0';
header('X-NovaCPX-Version: ' . trim($_ver));
// CORS for same-origin panel requests (ports 8880/8881/8882/8883)
// CORS — only allow same-host origins on the panel ports or the known proxy hostnames
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (preg_match('#^https?://[^/]+:(888[0-3])$#', $origin)) {
$_allowedHosts = ['novacpx.orbishosting.com', 'admin.novacpx.orbishosting.com',
'reseller.novacpx.orbishosting.com', 'panel.novacpx.orbishosting.com',
'web.orbishosting.com'];
$_originHost = parse_url($origin, PHP_URL_HOST) ?? '';
$_originPort = (int)(parse_url($origin, PHP_URL_PORT) ?? 0);
$_panelPorts = [8880, 8881, 8882, 8883]; // hardcoded — Core.php not loaded yet
if ($origin && (
in_array($_originHost, $_allowedHosts, true) ||
(in_array($_originPort, $_panelPorts, true) && filter_var($_originHost, FILTER_VALIDATE_IP))
)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
+398
View File
@@ -0,0 +1,398 @@
/* 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"], input[type="search"],
input[type="date"], input[type="time"], input[type="tel"],
input:not([type]), .form-control, 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;
box-sizing: border-box;
}
input:focus, select:focus, textarea:focus, .form-control:focus { border-color: var(--primary); }
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.5); }
.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 {
display: flex; align-items: center; justify-content: space-between;
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--text-muted);
padding: .25rem 1.25rem .5rem;
cursor: pointer; user-select: none;
transition: color .15s;
}
.sidebar-section-label:hover { color: var(--text); }
.sidebar-section-label .nav-chevron {
font-style: normal; font-size: .65rem; opacity: .5;
transition: transform .2s ease;
flex-shrink: 0;
}
.sidebar-section.collapsed .nav-chevron { transform: rotate(-90deg); }
.sidebar-section.collapsed .sidebar-link { display: none; }
.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; }
/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
#sidebar-toggle { display: none; }
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.5); z-index: 99;
}
.sidebar-overlay.open { display: block; }
/* page-header layout */
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
}
.page-title { font-size: 1.2rem; font-weight: 700; }
.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
/* panel utility */
.panel {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); margin-bottom: 1.5rem;
}
.panel-header {
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: .5rem;
}
.panel-title { font-size: .95rem; font-weight: 600; }
.panel-body { padding: 1.25rem; }
/* table alias */
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.table 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); white-space: nowrap; }
.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg3); }
/* btn variants */
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover { background: #0da271; }
.btn-warning { background: var(--yellow); color: #000; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: var(--red); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
/* badge alias */
.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
@media (max-width: 768px) {
#sidebar-toggle { display: flex; }
.sidebar {
transform: translateX(-100%);
transition: transform .25s ease;
z-index: 200;
}
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; }
.page-header { flex-direction: column; align-items: flex-start; }
.page-actions { width: 100%; }
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.modal { max-width: calc(100vw - 2rem); margin: 1rem; }
.panel-header { flex-direction: column; align-items: flex-start; }
.topbar { padding: .65rem 1rem; }
/* hide non-essential table columns on mobile */
.table th:nth-child(n+4),
.table td:nth-child(n+4) { display: none; }
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0d0f17"/>
<polygon points="16,3 27,9 27,21 16,27 5,21 5,9" fill="none" stroke="#6366f1" stroke-width="2"/>
<circle cx="16" cy="15" r="5" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="2 1.5"/>
<circle cx="16" cy="15" r="2.5" fill="#6366f1"/>
<circle cx="16" cy="9.5" r="1.2" fill="#6366f1"/>
<circle cx="21" cy="18" r="1.2" fill="#0ea5e9"/>
<circle cx="11" cy="18" r="1.2" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

+329
View File
@@ -0,0 +1,329 @@
<!-- NovaCPX Custom Icon Sprite — inline <use href="#icon-name"/> -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- Dashboard / home -->
<symbol id="ni-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="9" height="9" rx="1.5"/>
<rect x="13" y="3" width="9" height="5" rx="1.5"/>
<rect x="13" y="11" width="9" height="9" rx="1.5"/>
<rect x="2" y="15" width="9" height="5" rx="1.5"/>
</symbol>
<!-- Accounts / users -->
<symbol id="ni-accounts" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="7" r="4"/>
<path d="M2 21c0-4 3-7 7-7h4c4 0 7 3 7 7"/>
<circle cx="19" cy="9" r="2.5"/>
<path d="M22 19c0-2.5-1.5-4.5-3.5-5.5"/>
</symbol>
<!-- Resellers -->
<symbol id="ni-resellers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12,2 15,8.5 22,9.5 17,14 18.5,21 12,17.5 5.5,21 7,14 2,9.5 9,8.5"/>
</symbol>
<!-- Packages -->
<symbol id="ni-packages" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</symbol>
<!-- DNS -->
<symbol id="ni-dns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</symbol>
<!-- Email -->
<symbol id="ni-email" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"/>
<line x1="12" y1="13" x2="12" y2="20"/>
</symbol>
<!-- Databases -->
<symbol id="ni-databases" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"/>
</symbol>
<!-- FTP -->
<symbol id="ni-ftp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="8" height="6" rx="1"/>
<rect x="13" y="3" width="8" height="6" rx="1"/>
<rect x="3" y="13" width="8" height="6" rx="1"/>
<path d="M17 13v4m-2-2h4"/>
</symbol>
<!-- SSL / Lock -->
<symbol id="ni-ssl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="11" width="14" height="11" rx="2"/>
<path d="M8 11V7a4 4 0 1 1 8 0v4"/>
<circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/>
<line x1="12" y1="17.5" x2="12" y2="19.5"/>
</symbol>
<!-- PHP -->
<symbol id="ni-php" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="12" rx="10" ry="6"/>
<path d="M9 10v4m0-4h2a1.5 1.5 0 0 1 0 3H9"/>
<path d="M14 10v4m0-4h2a1.5 1.5 0 0 1 0 3H14"/>
</symbol>
<!-- Cron / clock -->
<symbol id="ni-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
<path d="M16 2l2 3M8 2L6 5"/>
</symbol>
<!-- File Manager -->
<symbol id="ni-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"/>
<line x1="9" y1="14" x2="15" y2="14"/>
<line x1="12" y1="11" x2="12" y2="17"/>
</symbol>
<!-- Domains / Web -->
<symbol id="ni-domains" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="13" rx="2"/>
<line x1="3" y1="7" x2="21" y2="7"/>
<line x1="7" y1="21" x2="17" y2="21"/>
<line x1="12" y1="16" x2="12" y2="21"/>
<circle cx="6" cy="5" r="0.8" fill="currentColor" stroke="none"/>
<circle cx="9" cy="5" r="0.8" fill="currentColor" stroke="none"/>
</symbol>
<!-- Server / System -->
<symbol id="ni-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="7" rx="1.5"/>
<rect x="2" y="13" width="20" height="7" rx="1.5"/>
<circle cx="6" cy="6.5" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="6" cy="16.5" r="1.2" fill="currentColor" stroke="none"/>
<line x1="10" y1="6.5" x2="16" y2="6.5"/>
<line x1="10" y1="16.5" x2="16" y2="16.5"/>
</symbol>
<!-- Features / Plugin -->
<symbol id="ni-features" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H9.5a1 1 0 0 0-.9.5l-4 7a1 1 0 0 0 0 1l4 7a1 1 0 0 0 .9.5h5a1 1 0 0 0 .9-.5l4-7a1 1 0 0 0 0-1l-4-7a1 1 0 0 0-.9-.5z"/>
<circle cx="12" cy="12" r="2.5"/>
</symbol>
<!-- Updates / Git -->
<symbol id="ni-updates" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
<polyline points="12 7 12 12 15 15"/>
</symbol>
<!-- Firewall / Shield -->
<symbol id="ni-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="15" x2="12.01" y2="15"/>
</symbol>
<!-- Backups -->
<symbol id="ni-backups" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 15 21 21 15 21"/>
<polyline points="3 9 3 3 9 3"/>
<path d="M21 3L14 10"/>
<path d="M3 21l7-7"/>
<circle cx="12" cy="12" r="3"/>
</symbol>
<!-- Settings / Gear -->
<symbol id="ni-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.5"/>
<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"/>
</symbol>
<!-- Audit Log -->
<symbol id="ni-audit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="12" y2="17"/>
<circle cx="16" cy="17" r="2"/>
<line x1="18" y1="19" x2="20" y2="21"/>
</symbol>
<!-- Webmail -->
<symbol id="ni-webmail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z"/>
<path d="M12 8a4 4 0 0 1 0 8 4 4 0 0 1 0-8z"/>
<path d="M16 12h6M2 12h6"/>
</symbol>
<!-- WordPress -->
<symbol id="ni-wordpress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2.48 12s3.22 7 9.52 7 9.52-7 9.52-7"/>
<path d="M12 2c2.8 2 5 5.8 5 10s-2.2 8-5 10"/>
<path d="M12 2c-2.8 2-5 5.8-5 10s2.2 8 5 10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
</symbol>
<!-- Docker -->
<symbol id="ni-docker" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="9" width="4" height="4" rx="0.5"/>
<rect x="7" y="9" width="4" height="4" rx="0.5"/>
<rect x="12" y="9" width="4" height="4" rx="0.5"/>
<rect x="7" y="4" width="4" height="4" rx="0.5"/>
<path d="M18 11a4 4 0 0 1 4 4c0 3-3 5-7 5H6c-2 0-4-1.5-4-4 0-1.8 1-3.5 3-4.5"/>
<path d="M20 7s-1-1-3-1"/>
<circle cx="20" cy="6" r="1" fill="currentColor" stroke="none"/>
</symbol>
<!-- Node.js -->
<symbol id="ni-nodejs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12,2 22,7.5 22,16.5 12,22 2,16.5 2,7.5"/>
<path d="M12 7v5l4 2.5"/>
</symbol>
<!-- Redis -->
<symbol id="ni-redis" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="8" rx="9" ry="3"/>
<path d="M3 8v4c0 1.66 4.03 3 9 3s9-1.34 9-3V8"/>
<path d="M3 12v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
<path d="M8 6l2 1 4-2"/>
</symbol>
<!-- Cloudflare -->
<symbol id="ni-cloudflare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.5 16c.83-1.5.5-4-2.5-4.5l.3-1.5C18 9.5 21 11 21 14.5c0 1-.2 2-.8 2.5H17.5z"/>
<path d="M6.5 16H17m-3-4.5C12.5 8 8 8 6 10.5c-1 1.5-1 3-0.5 4.5"/>
<path d="M3 15c0 .55.45 1 1 1h1"/>
</symbol>
<!-- Gitea -->
<symbol id="ni-git" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="18" r="3"/>
<circle cx="6" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M6 9v6"/>
<path d="M15.7 5.7l-9.4 12.6"/>
</symbol>
<!-- Suspend / pause -->
<symbol id="ni-suspend" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="10" y1="8" x2="10" y2="16"/>
<line x1="14" y1="8" x2="14" y2="16"/>
</symbol>
<!-- Terminate / X -->
<symbol id="ni-terminate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</symbol>
<!-- Stats / Chart -->
<symbol id="ni-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</symbol>
<!-- Notifications / bell -->
<symbol id="ni-notifications" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</symbol>
<!-- Hostname -->
<symbol id="ni-hostname" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<line x1="12" y1="15" x2="12" y2="18"/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none"/>
</symbol>
<!-- Add / Plus -->
<symbol id="ni-add" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</symbol>
<!-- User profile -->
<symbol id="ni-profile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
</symbol>
<!-- Logout -->
<symbol id="ni-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"/>
</symbol>
<!-- Search -->
<symbol id="ni-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</symbol>
<!-- Copy -->
<symbol id="ni-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</symbol>
<!-- Key / API -->
<symbol id="ni-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</symbol>
<!-- Mail server -->
<symbol id="ni-mailserver" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="20" height="14" rx="2"/>
<path d="M22 6l-10 7L2 6"/>
<path d="M2 6l4-4h12l4 4"/>
</symbol>
<!-- Docs / book -->
<symbol id="ni-docs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<line x1="8" y1="7" x2="16" y2="7"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</symbol>
<!-- Contact / support -->
<symbol id="ni-support" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="9.01" y2="10"/>
<line x1="12" y1="10" x2="12.01" y2="10"/>
<line x1="15" y1="10" x2="15.01" y2="10"/>
</symbol>
<!-- ModSecurity / WAF -->
<symbol id="ni-waf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 11 14 15 10"/>
</symbol>
<!-- Varnish / cache -->
<symbol id="ni-cache" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</symbol>
<!-- Network / IP -->
<symbol id="ni-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<rect x="2" y="18" width="6" height="4" rx="1"/>
<rect x="16" y="18" width="6" height="4" rx="1"/>
<rect x="9" y="18" width="6" height="4" rx="1"/>
<line x1="12" y1="6" x2="12" y2="14"/>
<path d="M5 20v-6h14v6"/>
<line x1="12" y1="14" x2="5" y2="14"/>
<line x1="12" y1="14" x2="19" y2="14"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48">
<defs>
<linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="60%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#10b981"/>
</linearGradient>
<linearGradient id="ng2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366f1" stop-opacity="1"/>
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0.7"/>
</linearGradient>
</defs>
<!-- Hexagon core mark -->
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#ng)" stroke-width="2.5"/>
<!-- Inner orbit ring -->
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#ng)" stroke-width="1.5" stroke-dasharray="3 2"/>
<!-- Central dot -->
<circle cx="24" cy="22" r="3" fill="url(#ng)"/>
<!-- Orbit node dots -->
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
<!-- Wordmark -->
<text x="52" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="700" fill="url(#ng)" letter-spacing="-0.5">Nova</text>
<text x="118" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="300" fill="#94a3b8" letter-spacing="2">CPX</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="mg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="60%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#10b981"/>
</linearGradient>
</defs>
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#mg)" stroke-width="2.5"/>
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#mg)" stroke-width="1.5" stroke-dasharray="3 2"/>
<circle cx="24" cy="22" r="3" fill="url(#mg)"/>
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+1463 -171
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
/**
* NovaCPX Feature Manager
* Loaded by admin panel's features page
*/
window.FeaturesManager = {
async load() {
const res = await Nova.api('features', 'list');
if (!res?.success) return '<p class="text-muted">Failed to load features.</p>';
const grouped = res.data;
const categoryIcons = {
'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧',
'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳',
'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡',
'Development':'👨‍💻', 'One-Click Apps':'🚀', 'Applications':'📦',
'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅',
};
return `
<div class="flex justify-between items-center mb-3">
<h2 style="font-size:1.1rem;font-weight:700">Feature Manager</h2>
<div class="flex gap-1">
<input type="text" id="feat-search" placeholder="Search features…" style="width:220px;padding:.45rem .85rem;font-size:.85rem">
<select id="feat-cat-filter" style="padding:.45rem .7rem;font-size:.85rem">
<option value="">All Categories</option>
${Object.keys(grouped).map(c => `<option value="${c}">${c}</option>`).join('')}
</select>
</div>
</div>
<div id="features-container">
${Object.entries(grouped).map(([cat, feats]) => `
<div class="feat-category" data-cat="${cat}">
<div class="flex items-center gap-1 mb-2 mt-3">
<span style="font-size:1.1rem">${categoryIcons[cat] || '🔧'}</span>
<h3 style="font-size:.9rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)">${cat}</h3>
<span class="badge badge-gray" style="margin-left:.5rem">${feats.length}</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.75rem">
${feats.map(f => `
<div class="feat-card card" data-id="${f.id}" data-name="${f.name.toLowerCase()}" data-cat="${cat}">
<div class="card-body" style="padding:1rem">
<div class="flex justify-between items-center">
<div>
<div style="font-weight:600;font-size:.88rem">${f.name}</div>
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.15rem;line-height:1.4">${f.description}</div>
${f.min_ram_mb > 0 ? `<div style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">Requires ${f.min_ram_mb}MB RAM</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:.4rem;margin-left:1rem;flex-shrink:0">
${f.installed
? `<label class="toggle-switch" title="${f.enabled ? 'Disable' : 'Enable'}">
<input type="checkbox" ${f.enabled ? 'checked' : ''} onchange="FeaturesManager.toggle(${f.id}, this.checked)">
<span class="toggle-slider"></span>
</label>`
: `<button class="btn btn-primary btn-sm" onclick="FeaturesManager.install(${f.id})">Install</button>`
}
${f.installed ? `<span class="badge badge-green" style="font-size:.65rem">Installed</span>` : `<span class="badge badge-gray" style="font-size:.65rem">Not installed</span>`}
</div>
</div>
</div>
</div>`).join('')}
</div>
</div>`).join('')}
</div>
<style>
.toggle-switch { position:relative; display:inline-block; width:40px; height:22px; }
.toggle-switch input { opacity:0; width:0; height:0; }
.toggle-slider {
position:absolute; cursor:pointer; inset:0;
background:var(--border); border-radius:999px; transition:.2s;
}
.toggle-slider:before {
content:''; position:absolute; width:16px; height:16px;
left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.2s;
}
input:checked + .toggle-slider { background:var(--primary); }
input:checked + .toggle-slider:before { transform:translateX(18px); }
</style>`;
},
async toggle(id, enable) {
const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } });
if (res?.data?.action === 'install_required') {
Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id));
return;
}
Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error');
},
async install(id) {
const res = await Nova.api('features', 'install', { method: 'POST', body: { id } });
if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; }
const logDiv = `<div class="terminal" id="install-log-${id}" style="min-height:100px">Starting installation…</div>`;
const ov = Nova.modal('Installing Feature', logDiv);
const poll = setInterval(async () => {
const logRes = await Nova.api('features', 'install-log', { params: { id } });
const logEl = document.getElementById(`install-log-${id}`);
if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `<div>${l}</div>`).join('');
if (!logRes?.data?.running) {
clearInterval(poll);
if (logRes?.data?.installed) {
Nova.toast('Feature installed successfully', 'success');
ov.remove();
document.getElementById('page-content').innerHTML = await this.load();
this.bindSearch();
}
}
}, 2000);
},
bindSearch() {
const search = document.getElementById('feat-search');
const catFilter = document.getElementById('feat-cat-filter');
if (!search || !catFilter) return;
const filter = () => {
const q = search.value.toLowerCase();
const cat = catFilter.value;
document.querySelectorAll('.feat-card').forEach(c => {
const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat);
c.closest('div').style.display = match ? '' : 'none';
});
document.querySelectorAll('.feat-category').forEach(sec => {
const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none');
sec.style.display = visible ? '' : 'none';
});
};
search.addEventListener('input', filter);
catFilter.addEventListener('change', filter);
},
};
+242
View File
@@ -0,0 +1,242 @@
/**
* NovaCPX Shared JS utilities
*/
window.Nova = (() => {
// ── Activity bar (thin top-of-page progress stripe for every API call) ────
let _barEl = null, _barPct = 0, _barTimer = null, _barActive = 0;
function _barShow() {
_barActive++;
if (!_barEl) {
_barEl = document.createElement('div');
_barEl.style.cssText = [
'position:fixed;top:0;left:0;z-index:999999',
'height:3px;width:0%;background:var(--primary,#6366f1)',
'transition:width .2s ease,opacity .3s ease',
'box-shadow:0 0 8px var(--primary,#6366f1)',
'pointer-events:none',
].join(';');
document.body.appendChild(_barEl);
}
_barEl.style.opacity = '1';
_barPct = 10;
_barEl.style.width = _barPct + '%';
clearInterval(_barTimer);
_barTimer = setInterval(() => {
if (_barEl && _barPct < 85) { _barPct += (_barPct < 50 ? 8 : _barPct < 70 ? 4 : 1); _barEl.style.width = _barPct + '%'; }
}, 200);
}
function _barDone() {
_barActive = Math.max(0, _barActive - 1);
if (_barActive > 0) return;
clearInterval(_barTimer);
if (_barEl) {
_barEl.style.width = '100%';
setTimeout(() => { if (_barEl) { _barEl.style.opacity = '0'; setTimeout(() => { _barEl?.remove(); _barEl = null; }, 300); } }, 200);
}
}
// ── API ───────────────────────────────────────────────────────────────────
async function api(endpoint, action, opts = {}) {
const { method = 'GET', body, params } = opts;
let url = `/api/${endpoint}/${action}`;
if (params) url += '?' + new URLSearchParams(params);
_barShow();
let res;
try {
res = await fetch(url, {
method,
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
_barDone();
console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
return { success: false, message: 'Network error — check your connection' };
}
_barDone();
if (res.status === 401) {
const text401 = await res.text();
try {
const j = JSON.parse(text401);
// Login failures return the real message; other 401s mean the session expired
if (endpoint === 'auth' && action === 'login') return j;
return { success: false, message: j.message || 'Session expired — please log in again' };
} catch { return { success: false, message: 'Session expired — please log in again' }; }
}
if (res.status === 429) {
const reset = res.headers.get('X-RateLimit-Reset');
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
return { success: false, message: `Rate limited — try again in ${wait}s` };
}
const text = await res.text();
try {
return JSON.parse(text);
} catch {
console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
}
}
// ── 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()">&times;</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>`;
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── Loading overlay ───────────────────────────────────────────────────────
let _loadingEl = null;
let _loadingCount = 0;
function loading(msg = 'Working…') {
_loadingCount++;
if (!_loadingEl) {
_loadingEl = document.createElement('div');
_loadingEl.id = 'nova-loading-overlay';
_loadingEl.style.cssText = [
'position:fixed;inset:0;z-index:99999',
'background:rgba(0,0,0,.55)',
'display:flex;flex-direction:column;align-items:center;justify-content:center',
'gap:1rem;animation:fadeIn .15s',
].join(';');
_loadingEl.innerHTML = `
<div style="width:48px;height:48px;border:4px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:ncpxSpin 0.7s linear infinite"></div>
<div id="nova-loading-msg" style="color:#fff;font-size:1rem;font-weight:500;text-shadow:0 1px 3px rgba(0,0,0,.6)">${escHtml(msg)}</div>`;
document.body.appendChild(_loadingEl);
} else {
document.getElementById('nova-loading-msg').textContent = msg;
}
}
function loadingDone() {
_loadingCount = Math.max(0, _loadingCount - 1);
if (_loadingCount === 0 && _loadingEl) {
_loadingEl.remove();
_loadingEl = null;
}
}
// Inject global CSS animations
const style = document.createElement('style');
style.textContent = [
'@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}',
'@keyframes ncpxSpin{to{transform:rotate(360deg)}}',
].join('');
document.head.appendChild(style);
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
})();
// #26 Mobile sidebar toggle — shared across all panels
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (!toggle || !sidebar) return;
const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
overlay?.addEventListener('click', close);
// Close when a nav link is clicked on mobile
sidebar.querySelectorAll('.sidebar-link').forEach(link =>
link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
);
});
+700
View File
@@ -0,0 +1,700 @@
/**
* NovaCPX Reseller Panel JS
*/
let _rUser = null;
async function initReseller() {
const res = await Nova.api('auth', 'me');
if (!res?.success || !['admin','reseller'].includes(res.data?.role)) {
document.getElementById('auth-check').innerHTML = renderLogin();
document.getElementById('main-layout').style.display = 'none';
return false;
}
_rUser = res.data;
document.getElementById('user-name').textContent = _rUser.username || 'Reseller';
document.getElementById('auth-check').style.display = 'none';
document.getElementById('main-layout').style.display = '';
return true;
}
function renderLogin() {
return `<div class="login-wrap">
<div class="login-card">
<div style="text-align:center;margin-bottom:2rem">
<img src="/assets/img/nova-logo.svg" style="height:42px;margin-bottom:.5rem">
<div style="color:var(--muted);font-size:.85rem">Reseller Portal · Port 8881</div>
</div>
<div class="form-group"><label class="form-label">Username</label><input id="li-user" type="text" class="form-control" autocomplete="username"></div>
<div class="form-group"><label class="form-label">Password</label><input id="li-pass" type="password" class="form-control" autocomplete="current-password"></div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Sign In</button>
<div id="li-err" style="color:var(--red);margin-top:.75rem;text-align:center;display:none"></div>
</div>
</div>`;
}
async function doLogin() {
Nova.loading('Signing in…');
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
Nova.loadingDone();
if (res?.success) {
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
else location.reload();
} else {
const err = document.getElementById('li-err');
if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
}
}
window.doLogin = doLogin;
/* ── Pages ─────────────────────────────────────────────────────────────── */
async function rDashboard(el) {
el.innerHTML = `<div class="page-header"><h2 class="page-title">Reseller Dashboard</h2></div>
<div id="r-stats" class="stats-grid"><div class="loading">Loading</div></div>
<div style="margin-top:1.5rem" class="card">
<div class="card-header"><span class="card-title">Recent Accounts</span>
<button class="btn btn-sm btn-primary" onclick="resellerNav('accounts')">View All</button>
</div>
<div id="r-recent"><div class="loading">Loading</div></div>
</div>`;
const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
const accts = res?.data || [];
document.getElementById('r-stats').innerHTML = [
{ label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' },
{ label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' },
{ label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' },
].map(s => `<div class="stat-card" style="display:flex;align-items:center;gap:1rem">
<svg width="32" height="32" style="color:var(--primary);flex-shrink:0"><use href="/assets/img/nova-icons.svg#${s.icon}"/></svg>
<div><div class="stat-value">${s.val}</div><div class="stat-label">${s.label}</div></div>
</div>`).join('');
document.getElementById('r-recent').innerHTML = accts.length
? `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Status</th></tr></thead><tbody>
${accts.map(a => `<tr>
<td>${a.username}</td><td>${a.domain}</td><td>${a.package_name||''}</td>
<td>${Nova.badge(a.status, a.status==='active'?'green':'yellow')}</td>
</tr>`).join('')}
</tbody></table>`
: '<div class="empty">No accounts yet.</div>';
}
async function rAccounts(el) {
el.innerHTML = `<div class="page-header">
<h2 class="page-title">Hosting Accounts</h2>
<button class="btn btn-primary btn-sm" onclick="resellerNav('createAccount')">+ Create Account</button>
</div>
<div class="card">
<div style="padding:.75rem;border-bottom:1px solid var(--border)">
<input id="r-search" class="form-control" placeholder="Search accounts…" oninput="rSearchAccounts(this.value)" style="max-width:300px">
</div>
<div id="r-accounts-list"><div class="loading">Loading</div></div>
</div>`;
loadRAccounts();
}
async function loadRAccounts(search = '') {
const el = document.getElementById('r-accounts-list');
if (!el) return;
const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}});
const acctRows = res?.data || [];
if (!res?.success || !acctRows.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
${acctRows.map(a => `<tr>
<td><strong>${Nova.escHtml(a.username)}</strong></td>
<td>${Nova.escHtml(a.domain)}</td>
<td>${a.package_name ? Nova.escHtml(a.package_name) : '—'}</td>
<td>${a.disk_usage_mb || 0} MB</td>
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
<button class="btn btn-xs btn-primary" onclick="rLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
${a.status === 'active'
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
<button class="btn btn-xs" onclick="rChangePass(${a.id},'${a.username}')">Passwd</button>
<button class="btn btn-xs btn-danger" onclick="rTerminate(${a.id},'${a.username}')">Terminate</button>
</td>
</tr>`).join('')}
</tbody></table>`;
}
window.loadRAccounts = loadRAccounts;
window.rSearchAccounts = (v) => loadRAccounts(v);
window.rLoginAs = async (userId, username) => {
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
Nova.loading(`Switching to ${username}`);
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
Nova.loadingDone();
if (res?.success) {
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
} else {
Nova.toast(res?.message || 'Impersonation failed', 'error');
}
});
};
window.rSuspend = async (id, user) => {
Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); }
else Nova.toast(res?.message,'error');
});
};
window.rUnsuspend = async (id, user) => {
const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }});
if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); }
else Nova.toast(res?.message,'error');
};
window.rTerminate = (id, user) => {
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => {
const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }});
if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); }
else Nova.toast(res?.message,'error');
}, true);
};
window.rChangePass = (id, user) => {
Nova.modal(`Change Password — ${user}`, `<div class="form-group"><label class="form-label">New Password</label><input id="rp-pass" type="password" class="form-control"></div>`,
`<button class="btn btn-primary" onclick="Nova.api('accounts','change-password',{method:'POST',body:{account_id:${id},password:document.getElementById('rp-pass').value}}).then(r=>{if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Update</button>`);
};
async function rCreateAccount(el) {
el.innerHTML = `<div class="page-header"><h2 class="page-title">Create Hosting Account</h2></div>
<div class="card" style="max-width:600px">
<div style="padding:1.5rem">
<div class="form-group"><label class="form-label">Username <span style="color:var(--red)">*</span></label><input id="ca-user" class="form-control" placeholder="lowercase letters, numbers"></div>
<div class="form-group"><label class="form-label">Password <span style="color:var(--red)">*</span></label><input id="ca-pass" type="password" class="form-control"></div>
<div class="form-group"><label class="form-label">Email</label><input id="ca-email" type="email" class="form-control" placeholder="user@example.com"></div>
<div class="form-group"><label class="form-label">Primary Domain <span style="color:var(--red)">*</span></label><input id="ca-domain" class="form-control" placeholder="example.com"></div>
<div class="form-group"><label class="form-label">Package</label><select id="ca-pkg" class="form-control"><option value="">Loading</option></select></div>
<div style="margin-top:1.5rem;display:flex;gap:.75rem">
<button class="btn btn-primary" onclick="submitCreateAccount()">Create Account</button>
<button class="btn" onclick="resellerNav('accounts')">Cancel</button>
</div>
<div id="ca-result" style="margin-top:1rem"></div>
</div>
</div>`;
Nova.api('packages', 'list').then(res => {
const sel = document.getElementById('ca-pkg');
if (sel && res?.success) {
sel.innerHTML = res.data.map(p => `<option value="${p.id}">${p.name}${p.disk_mb}MB disk</option>`).join('');
}
});
}
window.submitCreateAccount = async () => {
const btn = document.querySelector('#ca-result');
if (btn) btn.textContent = '';
Nova.loading('Creating hosting account…');
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
username: document.getElementById('ca-user')?.value,
password: document.getElementById('ca-pass')?.value,
email: document.getElementById('ca-email')?.value,
domain: document.getElementById('ca-domain')?.value,
package_id: document.getElementById('ca-pkg')?.value,
}});
Nova.loadingDone();
if (res?.success) {
Nova.toast('Account created successfully!','success');
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
} else {
Nova.toast(res?.message || 'Failed to create account','error');
if (btn) btn.innerHTML = `<div class="alert alert-error">${res?.message || 'Error'}</div>`;
}
};
async function rPackages(el) {
el.innerHTML = `<div class="page-header">
<h2 class="page-title">Packages</h2>
<button class="btn btn-primary btn-sm" onclick="rAddPackage()">+ Add Package</button>
</div>
<div class="card"><div id="pkg-list"><div class="loading">Loading</div></div></div>`;
const res = await Nova.api('packages', 'list');
const plist = document.getElementById('pkg-list');
if (!res?.success || !res.data.length) { plist.innerHTML = '<div class="empty">No packages yet.</div>'; return; }
plist.innerHTML = `<table class="table"><thead><tr><th>Name</th><th>Disk</th><th>BW</th><th>DBs</th><th>Emails</th><th>Domains</th><th>Price</th><th>Actions</th></tr></thead><tbody>
${res.data.map(p => `<tr>
<td><strong>${p.name}</strong></td>
<td>${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}</td>
<td>${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}</td>
<td>${p.databases || '∞'}</td>
<td>${p.email_accounts || '∞'}</td>
<td>${p.addon_domains || '∞'}</td>
<td>${p.price ? '$'+p.price : 'Free'}</td>
<td style="display:flex;gap:.25rem">
<button class="btn btn-xs" onclick="rEditPackage(${p.id})">Edit</button>
<button class="btn btn-xs btn-danger" onclick="rDeletePackage(${p.id},'${p.name}')">Del</button>
</td>
</tr>`).join('')}
</tbody></table>`;
}
window.rAddPackage = () => showPackageModal();
window.rEditPackage = async (id) => {
const res = await Nova.api('packages', 'get', { params:{ id }});
if (res?.success) showPackageModal(res.data);
};
function showPackageModal(pkg = null) {
const p = pkg || {};
Nova.modal(pkg ? 'Edit Package' : 'Add Package', `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div class="form-group" style="grid-column:1/-1"><label class="form-label">Package Name</label><input id="pk-name" class="form-control" value="${p.name||''}"></div>
<div class="form-group"><label class="form-label">Disk (MB, 0=)</label><input id="pk-disk" type="number" class="form-control" value="${p.disk_mb||0}"></div>
<div class="form-group"><label class="form-label">Bandwidth (MB)</label><input id="pk-bw" type="number" class="form-control" value="${p.bandwidth_mb||0}"></div>
<div class="form-group"><label class="form-label">Databases</label><input id="pk-db" type="number" class="form-control" value="${p.databases||0}"></div>
<div class="form-group"><label class="form-label">Email Accounts</label><input id="pk-email" type="number" class="form-control" value="${p.email_accounts||0}"></div>
<div class="form-group"><label class="form-label">Addon Domains</label><input id="pk-adom" type="number" class="form-control" value="${p.addon_domains||0}"></div>
<div class="form-group"><label class="form-label">Subdomains</label><input id="pk-sub" type="number" class="form-control" value="${p.subdomains||0}"></div>
<div class="form-group"><label class="form-label">FTP Accounts</label><input id="pk-ftp" type="number" class="form-control" value="${p.ftp_accounts||0}"></div>
<div class="form-group"><label class="form-label">Price ($/mo)</label><input id="pk-price" type="number" step="0.01" class="form-control" value="${p.price||0}"></div>
</div>`,
`<button class="btn btn-primary" onclick="submitPackage(${p.id||'null'})">Save</button>`);
}
window.submitPackage = async (id) => {
const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) };
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); }
else Nova.toast(res?.message,'error');
};
window.rDeletePackage = (id, name) => {
Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => {
const res = await Nova.api('packages','delete',{method:'POST',body:{id}});
if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); }
else Nova.toast(res?.message,'error');
}, true);
};
async function rDNS(el) {
el.innerHTML = `<div class="page-header"><h2 class="page-title">DNS Zones</h2></div>
<div class="card"><div id="r-dns-list"><div class="loading">Loading</div></div></div>`;
const res = await Nova.api('dns', 'zones');
const list = document.getElementById('r-dns-list');
if (!res?.success || !res.data.length) { list.innerHTML = '<div class="empty">No DNS zones.</div>'; return; }
list.innerHTML = `<table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Records</th><th>Actions</th></tr></thead><tbody>
${res.data.map(z => `<tr>
<td>${z.domain}</td>
<td>${z.username||'—'}</td>
<td>${z.record_count||0}</td>
<td><button class="btn btn-xs" onclick="rViewZone(${z.id},'${z.domain}')">Edit Records</button></td>
</tr>`).join('')}
</tbody></table>`;
}
window.rViewZone = async (zoneId, domain) => {
const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }});
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
const rows = res.data.map(r => `<tr>
<td>${r.name}</td><td>${Nova.badge(r.type,'default')}</td><td><code style="font-size:.8rem">${r.value}</code></td><td>${r.ttl}</td>
<td><button class="btn btn-xs btn-danger" onclick="rDeleteRecord(${r.id},${zoneId},'${domain}')">Del</button></td>
</tr>`).join('');
Nova.modal(`DNS Records — ${domain}`,
`<button class="btn btn-sm btn-primary" style="margin-bottom:.75rem" onclick="rAddRecord(${zoneId},'${domain}')">+ Add Record</button>
<table class="table"><thead><tr><th>Name</th><th>Type</th><th>Value</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`);
};
window.rAddRecord = (zoneId, domain) => {
Nova.modal('Add DNS Record', `
<div class="form-group"><label class="form-label">Name</label><input id="dns-name" class="form-control" placeholder="@ or subdomain"></div>
<div class="form-group"><label class="form-label">Type</label><select id="dns-type" class="form-control"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>SRV</option></select></div>
<div class="form-group"><label class="form-label">Value</label><input id="dns-val" class="form-control"></div>
<div class="form-group"><label class="form-label">TTL</label><input id="dns-ttl" type="number" class="form-control" value="3600"></div>
<div class="form-group"><label class="form-label">Priority (MX)</label><input id="dns-pri" type="number" class="form-control" value="10"></div>`,
`<button class="btn btn-primary" onclick="Nova.api('dns','add-record',{method:'POST',body:{zone_id:${zoneId},name:document.getElementById('dns-name').value,type:document.getElementById('dns-type').value,value:document.getElementById('dns-val').value,ttl:parseInt(document.getElementById('dns-ttl').value),priority:parseInt(document.getElementById('dns-pri').value)}}).then(r=>{if(r?.success){Nova.toast('Record added','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Add Record</button>`);
};
window.rDeleteRecord = async (id, zoneId, domain) => {
Nova.confirm('Delete this DNS record?', async () => {
const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }});
if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); }
else Nova.toast(res?.message,'error');
}, true);
};
/* ── Nav ────────────────────────────────────────────────────────────────── */
const rNavGroups = [
{ label: 'Overview', items: [
{ id: 'dashboard', label: 'Dashboard',
svg: '<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>' },
]},
{ label: 'Accounts', items: [
{ id: 'accounts', label: 'All Accounts',
svg: '<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>' },
{ id: 'createAccount', label: 'New Account',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="17" y1="11" x2="23" y2="11"/></svg>' },
{ id: 'packages', label: 'Packages',
svg: '<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"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
]},
{ label: 'DNS', items: [
{ id: 'dns', label: 'DNS Zones',
svg: '<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>' },
]},
{ label: 'Tools', items: [
{ id: 'docker', label: 'Docker',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
{ id: 'whitelabel', label: 'White Label',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>' },
]},
];
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
let _rActivePage = 'dashboard';
function renderRNav() {
const nav = document.getElementById('sidebar-nav');
if (!nav) return;
nav.innerHTML = rNavGroups.map(g => `
<div class="sidebar-section">
<div class="sidebar-section-label">${g.label}</div>
${g.items.map(n => `
<a href="#" class="sidebar-link${n.id === _rActivePage ? ' active' : ''}" data-page="${n.id}">
${n.svg}
${n.label}
</a>`).join('')}
</div>`).join('');
nav.querySelectorAll('[data-page]').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
if (window.innerWidth <= 768) {
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebar-overlay')?.classList.remove('open');
document.body.style.overflow = '';
}
resellerNav(link.dataset.page);
});
});
}
window.resellerNav = (page) => {
_rActivePage = page;
renderRNav();
const allItems = rNavGroups.flatMap(g => g.items);
const item = allItems.find(n => n.id === page);
const titleEl = document.getElementById('page-title');
if (titleEl && item) titleEl.textContent = item.label;
const content = document.getElementById('page-content');
if (!content) return;
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
if (rPages[page]) rPages[page](content);
};
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initReseller();
if (!ok) return;
document.getElementById('logout-btn')?.addEventListener('click', async e => {
e.preventDefault();
await Nova.api('auth', 'logout', { method: 'POST' });
location.href = '/';
});
renderRNav();
window.resellerNav('dashboard');
});
/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
async function rDocker(el) {
el.innerHTML = '<div class="loading">Loading…</div>';
const [stRes, acctRes] = await Promise.all([
Nova.api('docker', 'stacks'),
Nova.api('accounts', 'list', { params: { limit: 200 } }),
]);
const stacks = stRes?.data?.stacks || [];
const accts = acctRes?.data || [];
el.innerHTML = `
<div class="page-header"><h2 class="page-title">Docker</h2></div>
<p class="text-muted" style="margin-bottom:1.5rem">Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.</p>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<button class="btn btn-sm ${_rDockerTab==='containers'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('containers')">Containers</button>
<button class="btn btn-sm ${_rDockerTab==='quotas'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('quotas')">Customer Quotas</button>
<button class="btn btn-sm ${_rDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('catalog')">App Catalog</button>
</div>
<div id="rdocker-content"><div class="loading">Loading</div></div>`;
window._rDockerAccts = accts;
window._rDockerTab = window._rDockerTab || 'containers';
window.rDockerTab = async (tab) => {
window._rDockerTab = tab;
document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
});
await rDockerLoadTab(tab);
};
await rDockerLoadTab(window._rDockerTab);
}
window._rDockerTab = 'containers';
async function rDockerLoadTab(tab) {
const tc = document.getElementById('rdocker-content');
if (!tc) return;
tc.innerHTML = '<div class="loading">Loading…</div>';
if (tab === 'containers') {
const r = await Nova.api('docker', 'containers');
const rows = r?.data?.containers || [];
tc.innerHTML = rows.length === 0
? '<div class="text-muted" style="padding:2rem;text-align:center">No containers for your accounts</div>'
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Actions</th></tr></thead><tbody>
${rows.map(c=>`<tr>
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
<td>${Nova.badge(c.status,c.status==='running'?'green':'red')}</td>
<td>${c.account_id||'—'}</td>
<td>
${c.status==='running'
? `<button class="btn btn-xs btn-warning" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>`
: `<button class="btn btn-xs btn-success" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
<button class="btn btn-xs btn-ghost" onclick="rDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
</td>
</tr>`).join('')}
</tbody></table></div>`;
} else if (tab === 'quotas') {
const accts = window._rDockerAccts || [];
tc.innerHTML = accts.length === 0
? '<div class="text-muted" style="padding:2rem;text-align:center">No accounts</div>'
: `<p class="text-muted" style="margin-bottom:1rem">Set Docker limits for each of your customers.</p>
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
${accts.map(u=>`<tr>
<td>${Nova.escHtml(u.username)}</td>
<td>2</td><td>512 MB</td><td>1.0</td>
<td><button class="btn btn-xs btn-primary" onclick="rDockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
</tr>`).join('')}
</tbody></table></div>`;
} else if (tab === 'catalog') {
const r = await Nova.api('docker', 'catalog');
const catalog = r?.data?.catalog || {};
const accts = window._rDockerAccts || [];
tc.innerHTML = `
<p class="text-muted" style="margin-bottom:1rem">Pre-install app stacks for your customers.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
${Object.entries(catalog).map(([key,app])=>`
<div class="card" style="cursor:pointer" onclick="rDockerLaunchModal('${key}','${Nova.escHtml(app.name)}')">
<div class="card-body" style="text-align:center;padding:1.5rem">
<div style="font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
<div style="font-weight:600">${Nova.escHtml(app.name)}</div>
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">${Nova.escHtml(app.description)}</div>
<button class="btn btn-sm btn-primary" style="margin-top:1rem">Deploy</button>
</div>
</div>`).join('')}
</div>`;
}
}
window.rDockerAct = async (cid, action) => {
Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`);
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
Nova.loadingDone();
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) rDockerLoadTab('containers');
};
window.rDockerLogs = async (cid, name) => {
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'')}</pre>`);
};
window.rDockerQuotaModal = (userId, username) => {
const ov = Nova.modal(`Docker Quota: ${username}`,
`<div class="form-group"><label>Max Containers</label><input id="rdq-cnt" type="number" class="form-control" value="2" min="0"></div>
<div class="form-group"><label>Max Memory (MB)</label><input id="rdq-mem" type="number" class="form-control" value="512" min="64"></div>
<div class="form-group"><label>Max CPUs</label><input id="rdq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="rDockerQuotaSubmit(${userId})">Save</button>`
);
window.rDockerQuotaSubmit = async (uid) => {
ov.remove();
const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
user_id: uid,
max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
}});
Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
};
};
window.rDockerLaunchModal = async (appKey, appName) => {
const catRes = await Nova.api('docker', 'catalog');
const app = catRes?.data?.catalog?.[appKey];
if (!app) return;
const accts = window._rDockerAccts || [];
const acctOpts = accts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)}</option>`).join('');
const paramFields = (app.params||[]).map(p=>`
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
<input id="rl-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
const ov = Nova.modal(`Deploy ${appName}`,
`<div class="form-group"><label>Account</label><select id="rl-acct" class="form-control"><option value="">Select account</option>${acctOpts}</select></div>${paramFields}`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="rDockerLaunchSubmit('${appKey}')">Deploy</button>`
);
window.rDockerLaunchSubmit = async (key) => {
const acctId = parseInt(document.getElementById('rl-acct').value)||0;
if (!acctId) { Nova.toast('Select an account','error'); return; }
const params = {};
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
ov.remove();
Nova.loading(`Deploying ${appName}… this may take a minute`);
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
Nova.loadingDone();
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
if (r?.success) rDockerLoadTab('containers');
};
};
// ── White Label / Branding (#18) ────────────────────────────────────────────
async function rWhiteLabel(el) {
el.innerHTML = '<div class="page-loader">Loading…</div>';
const res = await Nova.api('branding', 'get');
const b = res?.data || {};
el.innerHTML = `
<div class="page-header"><h1 class="page-title">White Label Branding</h1></div>
<div class="grid-2" style="gap:1.5rem;align-items:start">
<div class="card">
<div class="card-header"><span class="card-title">Panel Identity</span></div>
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
<div class="form-group">
<label>Panel Name</label>
<input id="wl-name" class="form-control" value="${Nova.escHtml(b.panel_name||'NovaCPX')}" placeholder="NovaCPX">
</div>
<div class="form-group">
<label>Logo</label>
${b.logo_url ? `<div style="margin-bottom:.5rem"><img src="${Nova.escHtml(b.logo_url)}" style="max-height:50px;max-width:200px;border-radius:6px;background:var(--bg2);padding:.5rem"></div>` : ''}
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<label class="btn btn-ghost btn-sm" style="cursor:pointer">
Upload Logo <input type="file" id="wl-logo-file" accept="image/*" style="display:none" onchange="rWlUploadLogo()">
</label>
${b.logo_url ? `<button class="btn btn-ghost btn-sm" onclick="rWlDeleteLogo()" style="color:var(--danger)">Remove</button>` : ''}
<span class="text-muted text-sm">PNG/SVG/JPG · max 512 KB</span>
</div>
</div>
<div class="form-group">
<label>Custom CSS <span class="text-muted text-sm">(advanced)</span></label>
<textarea id="wl-css" class="form-control" rows="4" style="font-family:monospace;font-size:.8rem" placeholder="/* e.g. .sidebar { background: #1a1a2e; } */">${Nova.escHtml(b.custom_css||'')}</textarea>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:1.5rem">
<div class="card">
<div class="card-header"><span class="card-title">Colors</span></div>
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
<div class="form-group">
<label>Primary Color</label>
<div style="display:flex;gap:.5rem;align-items:center">
<input type="color" id="wl-primary" value="${Nova.escHtml(b.primary_color||'#6366f1')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
<input type="text" id="wl-primary-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.primary_color||'#6366f1')}" maxlength="7">
</div>
</div>
<div class="form-group">
<label>Accent Color</label>
<div style="display:flex;gap:.5rem;align-items:center">
<input type="color" id="wl-accent" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
<input type="text" id="wl-accent-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" maxlength="7">
</div>
</div>
<div id="wl-color-preview" style="height:40px;border-radius:8px;background:linear-gradient(135deg,${Nova.escHtml(b.primary_color||'#6366f1')},${Nova.escHtml(b.accent_color||'#0ea5e9')});transition:background .3s"></div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Support</span></div>
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
<div class="form-group">
<label>Support Email</label>
<input id="wl-email" class="form-control" type="email" value="${Nova.escHtml(b.support_email||'')}" placeholder="support@yourdomain.com">
</div>
<div class="form-group">
<label>Support URL</label>
<input id="wl-url" class="form-control" type="url" value="${Nova.escHtml(b.support_url||'')}" placeholder="https://support.yourdomain.com">
</div>
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="wl-hide-powered" ${b.hide_powered_by ? 'checked' : ''}>
Hide "Powered by NovaCPX" in panel footer
</label>
</div>
</div>
<div style="display:flex;gap:.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="rWhiteLabel(document.getElementById('page-content'))">Reset</button>
<button class="btn btn-primary" onclick="rWlSave()">Save Branding</button>
</div>
</div>
</div>`;
// Sync color pickers ↔ hex inputs ↔ preview
['primary','accent'].forEach(k => {
const picker = document.getElementById('wl-'+k);
const hex = document.getElementById('wl-'+k+'-hex');
const sync = () => {
if (picker) hex.value = picker.value;
rWlUpdatePreview();
};
const syncBack = () => {
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); }
};
picker?.addEventListener('input', sync);
hex?.addEventListener('input', syncBack);
});
}
function rWlUpdatePreview() {
const p = document.getElementById('wl-primary-hex')?.value || '#6366f1';
const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9';
const el = document.getElementById('wl-color-preview');
if (el) el.style.background = `linear-gradient(135deg,${p},${a})`;
// Live-preview CSS vars
const style = document.getElementById('reseller-branding') || (() => {
const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s;
})();
style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`;
}
window.rWlUploadLogo = async () => {
const file = document.getElementById('wl-logo-file')?.files?.[0];
if (!file) return;
if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; }
const fd = new FormData();
fd.append('logo', file);
Nova.toast('Uploading…', 'info', 5000);
try {
const res = await fetch('/api/branding/upload-logo', {
method: 'POST', credentials: 'include', body: fd
});
const data = await res.json();
Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'),
data?.success ? 'success' : 'error');
if (data?.success) rWhiteLabel(document.getElementById('page-content'));
} catch (e) { Nova.toast('Upload failed', 'error'); }
};
window.rWlDeleteLogo = async () => {
const r = await Nova.api('branding', 'delete-logo', { method: 'POST' });
Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) rWhiteLabel(document.getElementById('page-content'));
};
window.rWlSave = async () => {
const body = {
panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX',
primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1',
accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9',
support_email: document.getElementById('wl-email')?.value?.trim() || '',
support_url: document.getElementById('wl-url')?.value?.trim() || '',
hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
custom_css: document.getElementById('wl-css')?.value || '',
};
Nova.loading('Saving branding…');
const r = await Nova.api('branding', 'save', { method: 'POST', body });
Nova.loadingDone();
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
r?.success ? 'success' : 'error');
};
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
<?php http_response_code(404); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 Page Not Found · NovaCPX</title>
<style>
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
background-image:radial-gradient(ellipse at 30% 20%,rgba(99,102,241,.12) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(239,68,68,.07) 0%,transparent 60%)}
.wrap{text-align:center;padding:2rem;max-width:480px}
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,var(--primary),#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
.btn:hover{opacity:.85}
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
.logo svg{width:28px;height:28px}
.logo-text{font-size:1.1rem;font-weight:300}
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
<div class="logo-text">Nova<strong>CPX</strong></div>
</div>
<div class="code">404</div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a href="javascript:history.back()" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Go Back
</a>
</div>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<?php http_response_code(500); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>500 Server Error · NovaCPX</title>
<style>
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
background-image:radial-gradient(ellipse at 30% 20%,rgba(239,68,68,.1) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(99,102,241,.07) 0%,transparent 60%)}
.wrap{text-align:center;padding:2rem;max-width:480px}
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,#ef4444,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
.btn:hover{opacity:.85}
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
.logo svg{width:28px;height:28px}
.logo-text{font-size:1.1rem;font-weight:300}
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
<div class="logo-text">Nova<strong>CPX</strong></div>
</div>
<div class="code">500</div>
<h1>Internal Server Error</h1>
<p>Something went wrong on our end. The issue has been logged. Please try again in a moment.</p>
<a href="javascript:history.back()" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Go Back
</a>
</div>
</body>
</html>
+130
View File
@@ -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> &nbsp;|&nbsp;
<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');
// Each role redirects to its dedicated port
const dest = REDIRECT || data.data.portal_url || '/';
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>
+45 -25
View File
@@ -25,41 +25,61 @@ class AccountManager {
$docRoot = "{$homeDir}/public_html";
$password = $data['password'] ?? bin2hex(random_bytes(8));
// Create Linux user
// Create Linux user and home directory first
self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username));
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | chpasswd");
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | sudo chpasswd");
self::shell("sudo mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp");
self::shell("sudo chown -R {$username}:www-data {$homeDir}");
self::shell("sudo chmod 750 {$homeDir}"); self::shell("sudo chmod 775 {$docRoot}");
self::shell("sudo chmod 750 {$homeDir}");
self::shell("sudo chmod 775 {$docRoot}");
// Default index page
file_put_contents("{$docRoot}/index.html",
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>"
);
// Default index page — use custom template from settings if set, else built-in
$customTpl = null;
try {
$db2 = DB::getInstance();
$tplRow = $db2->fetchOne("SELECT value FROM settings WHERE key='default_index_template'");
$customTpl = $tplRow ? trim($tplRow['value']) : null;
} catch (Throwable $e) {}
// Save account to DB
$acctId = (int)$db->insert(
"INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
);
$html = $customTpl
? str_replace(['{domain}', '{username}'], [$domain, $username], $customTpl)
: "<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Welcome to {$domain}</title>\n<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e4f0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}.wrap{padding:3rem 2rem}.domain{font-size:2rem;font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:1rem}.sub{color:#8b90a8;font-size:1rem;margin-bottom:2rem}.badge{display:inline-block;padding:.4rem 1rem;border:1px solid #2e3350;border-radius:6px;font-size:.8rem;color:#8b90a8}</style>\n</head>\n<body><div class=\"wrap\">\n<div class=\"domain\">{$domain}</div>\n<p class=\"sub\">Your website is ready. Upload your files to get started.</p>\n<span class=\"badge\">Hosted by NovaCPX</span>\n</div></body></html>";
// Save domain
$db->insert(
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
[$acctId, $domain, 'main', $docRoot]
);
self::shell("sudo tee " . escapeshellarg("{$docRoot}/index.html") . " > /dev/null << 'HTMLEOF'\n{$html}\nHTMLEOF");
// Create web vhost
VhostManager::create($username, $domain, $docRoot, $phpVer);
// Wrap all DB writes in a transaction so partial failures leave no orphans
$db->beginTransaction();
try {
$acctId = (int)$db->insert(
"INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
);
// Create DNS zone
DNSManager::createZone($acctId, $domain);
$db->insert(
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
[$acctId, $domain, 'main', $docRoot]
);
// Auto-provision SPF, DKIM, DMARC records
self::provisionEmailDNS($acctId, $domain);
// Create web vhost
VhostManager::create($username, $domain, $docRoot, $phpVer);
// Create PHP-FPM pool
PHPManager::createPool($username, $phpVer);
// Create DNS zone
DNSManager::createZone($acctId, $domain);
// Auto-provision SPF, DKIM, DMARC records
self::provisionEmailDNS($acctId, $domain);
// Create PHP-FPM pool
PHPManager::createPool($username, $phpVer);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
// Clean up Linux user and PHP-FPM pool so orphaned configs can't crash php-fpm
self::shell("userdel -r " . escapeshellarg($username) . " 2>/dev/null || true");
PHPManager::removePool($username);
throw $e;
}
novacpx_log('info', "Account created: $username ($domain)");
return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir];
+24 -2
View File
@@ -150,13 +150,35 @@ class Auth {
* Used by login redirect so each role lands on the right port
*/
public static function portalUrl(string $role, string $path = '/'): string {
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$host = $_SERVER['HTTP_HOST'] ?? '';
// Allowlist of trusted hostnames — prevents open redirect via Host header injection
$allowed = [
'novacpx.orbishosting.com',
'admin.novacpx.orbishosting.com',
'reseller.novacpx.orbishosting.com',
'panel.novacpx.orbishosting.com',
'web.orbishosting.com',
];
$hostname = preg_replace('/:\d+$/', '', $host);
// Trusted proxy (no port) — stay on same host
if ($host && !preg_match('/:\d+$/', $host) && in_array($hostname, $allowed, true)) {
return "https://{$hostname}{$path}";
}
// Direct port access — validate hostname is a known server IP or allowed host
$port = match($role) {
'admin' => PORT_ADMIN,
'reseller' => PORT_RESELLER,
default => PORT_USER,
};
return "https://{$hostname}:{$port}{$path}";
// Only redirect to localhost/LAN IPs on direct panel access
if ($hostname && (filter_var($hostname, FILTER_VALIDATE_IP) || in_array($hostname, $allowed, true))) {
return "https://{$hostname}:{$port}{$path}";
}
// Fallback — safe relative path with no host (works for same-origin redirects)
return $path;
}
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -48,9 +48,7 @@ php_value[max_execution_time] = 30
public static function removePool(string $username): void {
foreach (['7.4','8.1','8.2','8.3'] as $ver) {
$file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf";
// Always attempt removal — don't rely on file_exists() which fails when
// www-data can't read the pool.d directory; rm -f is a no-op if missing
shell_exec("sudo rm -f " . escapeshellarg($file) . " 2>/dev/null");
shell_exec("sudo /bin/rm -f " . escapeshellarg($file) . " 2>/dev/null");
self::reloadFPM($ver);
}
}
@@ -118,6 +116,8 @@ php_value[max_execution_time] = 30
}
private static function reloadFPM(string $ver): void {
shell_exec("sudo systemctl reload php{$ver}-fpm 2>/dev/null || true");
// Write a flag file instead of reloading inline — the cron runner picks this up
// within 60s and reloads fpm outside of any HTTP request, avoiding 502s.
@file_put_contents('/tmp/novacpx-fpm-reload-' . $ver, '1');
}
}
+11
View File
@@ -139,10 +139,21 @@ input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacit
.sidebar-section { padding: .75rem 0; }
.sidebar-section-label {
display: flex; align-items: center; justify-content: space-between;
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--text-muted);
padding: .25rem 1.25rem .5rem;
cursor: pointer; user-select: none;
transition: color .15s;
}
.sidebar-section-label:hover { color: var(--text); }
.sidebar-section-label .nav-chevron {
font-style: normal; font-size: .65rem; opacity: .5;
transition: transform .2s ease;
flex-shrink: 0;
}
.sidebar-section.collapsed .nav-chevron { transform: rotate(-90deg); }
.sidebar-section.collapsed .sidebar-link { display: none; }
.sidebar-link {
display: flex; align-items: center; gap: .75rem;
padding: .55rem 1.25rem; text-decoration: none;
+397 -123
View File
@@ -75,7 +75,7 @@
// ── Page definitions ───────────────────────────────────────────────────────
const pages = {
dashboard,
'server-status': serverStatus,
'server-status': dashboard,
accounts,
resellers,
packages,
@@ -100,6 +100,8 @@
backups,
cloudflare,
'server-options': serverOptions,
'subdomains': window.adminSubdomains,
'parked-domains': window.adminParked,
notifications,
settings,
};
@@ -111,16 +113,18 @@
// ── Dashboard ──────────────────────────────────────────────────────────────
async function dashboard() {
const [stats, version] = await Promise.all([
const [stats, version, histRes] = await Promise.all([
Nova.api('system', 'stats'),
Nova.api('system', 'version'),
Nova.api('stats', 'server'),
]);
const s = stats?.data || {};
const v = version?.data || {};
const s = stats?.data || {};
const v = version?.data || {};
const hist = histRes?.data?.history || [];
document.getElementById('server-ip').textContent = '';
return `
const html = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">CPU Usage</div>
@@ -180,53 +184,26 @@
</div>
</div>
</div>
</div>
<div class="card" style="margin-top:1.5rem">
<div class="card-header">
<span class="card-title">24-Hour History</span>
<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">${hist.length} samples</span>
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="adminPage('dashboard')">&#x21BB;</button>
</div>
<div class="card-body">
${hist.length === 0
? '<p class="text-muted" style="text-align:center;padding:2rem">No history yet — collected every 5 minutes.</p>'
: '<canvas id="dash-hist-chart" height="70"></canvas>'}
</div>
</div>`;
// setTimeout BEFORE return so it actually executes (after return is dead code)
setTimeout(()=>{const c=document.getElementById('dash-hist-chart');if(!c||!hist.length)return;if(window.Chart){initStatsChart(c,hist);}else{const s2=document.createElement('script');s2.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';s2.onload=()=>initStatsChart(c,hist);document.head.appendChild(s2);}},150);
return html;
}
// ── Server Status ──────────────────────────────────────────────────────────
async function serverStatus() {
const [liveRes, histRes] = await Promise.all([
Nova.api('system', 'stats'),
Nova.api('stats', 'server'),
]);
const s = liveRes?.data || {};
const hist = histRes?.data?.history || [];
const html = `
<div class="page-header"><h2 class="page-title">Server Status</h2>
<button class="btn btn-ghost btn-sm" onclick="adminPage('server-status')"> Refresh</button>
</div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value ${(s.cpu?.pct||0)>80?'stat-red':'stat-green'}">${s.cpu?.pct??0}%</div><div class="mt-1">${Nova.progressBar(s.cpu?.pct||0)}</div></div>
<div class="stat-card"><div class="stat-label">RAM</div><div class="stat-value ${(s.ram?.pct||0)>80?'stat-red':'stat-blue'}">${s.ram?.pct??0}%</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||0)>85?'stat-red':'stat-yellow'}">${s.disk?.pct??0}%</div><div class="mt-1">${Nova.progressBar(s.disk?.pct||0)}</div></div>
<div class="stat-card"><div class="stat-label">Load Avg</div><div class="stat-value" style="font-size:1rem;padding-top:.4rem">${(s.cpu?.load||[0]).map(v=>v.toFixed(2)).join(' / ')}</div><div class="stat-sub">Uptime: ${s.uptime||''}</div></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">24-Hour History</span><span class="text-muted" style="font-size:.8rem">${hist.length} samples</span></div>
<div class="card-body">
${hist.length === 0
? '<p class="text-muted" style="text-align:center;padding:2rem">No history yet — stats are collected every 5 minutes.<br>Check that the collector cron is running: <code>*/5 * * * * root /usr/bin/php /opt/novacpx/bin/collect-stats.php</code></p>'
: '<canvas id="stats-chart" height="80"></canvas>'}
</div>
</div>`;
// Can't return html and async render chart — use a trick: render then init chart
setTimeout(() => {
const canvas = document.getElementById('stats-chart');
if (!canvas || !hist.length) return;
if (!window.Chart) {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';
s.onload = () => initStatsChart(canvas, hist);
document.head.appendChild(s);
} else {
initStatsChart(canvas, hist);
}
}, 100);
return html;
}
function initStatsChart(canvas, hist) {
const labels = hist.map(r => {
@@ -1166,7 +1143,7 @@
// ── Resellers ──────────────────────────────────────────────────────────────
async function resellers() {
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
const rows = res?.data || [];
return `
<div class="card">
@@ -1175,14 +1152,18 @@
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
</div>
<div id="reseller-table">
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody>
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
${rows.map(r => `<tr>
<td>${r.username}</td><td>${r.email||''}</td>
<td>${r.account_count||0}</td>
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td>
<td><strong>${Nova.escHtml(r.username)}</strong></td>
<td>${Nova.escHtml(r.email||'—')}</td>
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
<td style="display:flex;gap:.25rem">
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button>
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button>
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
${r.status === 'active'
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
</td>
</tr>`).join('')}
</tbody></table>`
@@ -1193,10 +1174,51 @@
window.adminAddReseller = () => {
Nova.modal('Create Reseller Account', `
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div>
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div>
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`,
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`);
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
`<button class="btn btn-primary" onclick="
Nova.api('users','create',{method:'POST',body:{
username:document.getElementById('ar-user').value,
email:document.getElementById('ar-email').value,
password:document.getElementById('ar-pass').value,
role:'reseller'
}}).then(r=>{
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
else Nova.toast(r?.message||'Error','error');
})">Create</button>`);
};
window.adminResellerPasswd = (id, user) => {
Nova.modal(`Change Password — ${user}`,
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
`<button class="btn btn-primary" onclick="
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
else Nova.toast(r?.message||'Error','error');
})">Update</button>`);
};
window.adminResellerSuspend = (id, user) => {
Nova.confirm(`Suspend reseller ${user}?`, async () => {
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
});
};
window.adminResellerUnsuspend = async (id) => {
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
};
window.adminResellerDelete = (id, user) => {
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
}, true);
};
// ── Packages ───────────────────────────────────────────────────────────────
@@ -1354,26 +1376,68 @@
// ── Web Server ────────────────────────────────────────────────────────────
async function webServer() {
const r = await Nova.api('system', 'stats');
const svcs = r?.data?.services || {};
const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2';
const [statsR, phpR] = await Promise.all([
Nova.api('system','stats'),
Nova.api('php','global-config'),
]);
const svcs = statsR?.data?.services || {};
const uptime = statsR?.data?.uptime || '—';
const cpu = statsR?.data?.cpu?.pct ?? '—';
const ram = statsR?.data?.ram?.pct ?? '—';
const phpCfg = phpR?.data || {};
window.wsLoadLog = async (log) => {
const r = await Nova.api('system','read-log',{params:{log}});
const el = document.getElementById('ws-log-out');
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
};
return `
<div class="card">
<div class="card-header"><span class="card-title">Web Server Management</span></div>
<div style="padding:1.5rem">
<div class="stats-grid" style="margin-bottom:1.5rem">
${Object.entries(svcs).map(([s,st]) => `<div class="stat-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
<strong style="word-break:break-all">${s}</strong><span data-svc-status="${s}">${Nova.badge(st,st==='active'?'green':'red')}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:.35rem">
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">Restart</button>
<button class="btn btn-xs" onclick="adminServiceAction('${s}','start')">Start</button>
<button class="btn btn-xs btn-danger" onclick="adminServiceAction('${s}','stop')">Stop</button>
</div>
</div>`).join('')}
<div class="page-header mb-2"><h2 class="page-title">Web Server</h2></div>
<div class="stats-grid mb-3">
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">${cpu}%</div></div>
<div class="stat-card"><div class="stat-label">RAM</div><div class="stat-value">${ram}%</div></div>
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-value stat-sm">${uptime}</div></div>
<div class="stat-card"><div class="stat-label">PHP</div><div class="stat-value stat-sm">${phpCfg.version||'8.3'}</div></div>
</div>
<div class="grid-2 mb-3">
<div class="card">
<div class="card-header"><span class="card-title">Services</span></div>
<div class="card-body">
${Object.entries(svcs).map(([s,st]) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
<span style="font-weight:500">${s} ${Nova.badge(st,st==='active'?'green':'red')}</span>
<div style="display:flex;gap:.35rem">
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')"> Restart</button>
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')"> Reload</button>
<button class="btn btn-xs btn-danger" onclick="adminServiceAction('${s}','stop')"> Stop</button>
</div>
</div>`).join('')}
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">PHP Defaults</span></div>
<div class="card-body">
${[['Version',phpCfg.version||'8.3'],['Memory Limit',phpCfg.memory_limit||'256M'],
['Upload Max',phpCfg.upload_max_filesize||'64M'],['Max Exec Time',(phpCfg.max_execution_time||30)+'s'],
['Post Max',phpCfg.post_max_size||'64M']].map(([k,v])=>`
<div style="display:flex;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--border);font-size:.85rem">
<span style="color:var(--text-muted)">${k}</span><strong>${v}</strong>
</div>`).join('')}
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Log Viewer</span>
<div style="display:flex;gap:.35rem;margin-left:auto">
${[['nginx-error','Nginx Error'],['nginx-access','Nginx Access'],['panel','Panel'],['deploy','Deploy']].map(([l,n])=>
`<button class="btn btn-xs" onclick="wsLoadLog('${l}')">${n}</button>`).join('')}
</div>
</div>
<pre id="ws-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:220px;overflow:auto;margin:0;border-radius:0 0 var(--radius) var(--radius);color:var(--text-muted)"> Click a log above to view it</pre>
</div>`;
}
@@ -2359,7 +2423,12 @@ ${dbs.map(d=>`<tr>
</tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>';
return `
<div class="page-header"><h2 class="page-title">Database Engine Manager</h2></div>
<div class="page-header mb-2"><h2 class="page-title">Database Manager</h2></div>
<div style="display:flex;gap:.5rem;margin-bottom:1.25rem;flex-wrap:wrap">
<a href="/phpmyadmin" target="_blank" class="btn btn-sm">🐬 phpMyAdmin (MySQL)</a>
<a href="/adminer.php" target="_blank" class="btn btn-sm">🗄 Adminer (MySQL)</a>
<a href="/adminer.php?pgsql=" target="_blank" class="btn btn-sm">🐘 Adminer (PostgreSQL)</a>
</div>
<div class="grid-3 gap-2" style="margin-bottom:1.5rem">
${engineCard('mysql', 'MySQL', '🐬')}
@@ -2386,6 +2455,7 @@ ${dbs.map(d=>`<tr>
<div class="grid-2 gap-2">
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)}
${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
${toolCard('adminer', 'Adminer', '🗄️', `http://${location.hostname}/adminer.php`)}
</div>
</div>
</div>
@@ -2562,37 +2632,71 @@ ${dbs.map(d=>`<tr>
// ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() {
const r = await Nova.api('system','stats');
const svcs = r?.data?.services || {};
const mailStatus = svcs['postfix'] || 'unknown';
const doveStatus = svcs['dovecot'] || 'unknown';
const [statsR, domainsR] = await Promise.all([
Nova.api('system','stats'),
Nova.api('email','domains'),
]);
const svcs = statsR?.data?.services || {};
const mailSvcs = ['postfix','dovecot','rspamd','opendkim'].filter(s => svcs[s]);
const domains = domainsR?.data || [];
window.msLoadLog = async () => {
const r = await Nova.api('system','read-log',{params:{log:'mail'}});
const el = document.getElementById('ms-log-out');
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
};
return `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div class="page-header mb-2"><h2 class="page-title">Mail Server</h2></div>
<div class="grid-2 mb-3">
<div class="card">
<div class="card-header"><span class="card-title">Mail Services</span></div>
<div style="padding:1.25rem">
${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 0;border-bottom:1px solid var(--border)">
<span>${s} <span data-svc-status="${s}">${Nova.badge(st,st==='active'?'green':'red')}</span></span>
<div style="display:flex;gap:.5rem">
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">Restart</button>
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">Reload</button>
<div class="card-header"><span class="card-title">Services</span></div>
<div class="card-body">
${mailSvcs.length ? mailSvcs.map(s => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
<span style="font-weight:500">${s} ${Nova.badge(svcs[s],svcs[s]==='active'?'green':'red')}</span>
<div style="display:flex;gap:.35rem">
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')"> Restart</button>
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')"> Reload</button>
</div>
</div>`).join('')}
</div>`).join('') : '<p class="text-muted">No mail services detected</p>'}
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Mail Queue</span></div>
<div style="padding:1.25rem">
<div class="card-header">
<span class="card-title">Mail Queue</span>
<button class="btn btn-xs btn-warning" style="margin-left:auto" onclick="Nova.confirm('Flush all queued mail?',()=>adminServiceAction('postfix','flush'))">Flush</button>
</div>
<div class="card-body">
<button class="btn btn-sm" onclick="adminViewMailQueue()">View Queue</button>
<button class="btn btn-sm btn-warning" style="margin-top:.5rem" onclick="Nova.confirm('Flush mail queue?',()=>adminServiceAction('postfix','flush'))">Flush Queue</button>
<div id="ms-queue-out" style="margin-top:.75rem;font-size:.82rem;color:var(--text-muted)"></div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Virtual Mail Domains (${domains.length})</span>
</div>
${domains.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Email Accounts</th></tr></thead><tbody>
${domains.map(d=>`<tr><td><strong>${Nova.escHtml(d.domain)}</strong></td><td>${Nova.escHtml(d.username||'—')}</td><td>${d.email_count||0}</td></tr>`).join('')}
</tbody></table></div>` : '<div class="card-body text-muted">No mail domains yet created automatically when hosting accounts are set up.</div>'}
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Mail Log</span>
<button class="btn btn-xs" style="margin-left:auto" onclick="msLoadLog()">Load Log</button>
</div>
<pre id="ms-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:180px;overflow:auto;margin:0;color:var(--text-muted)"> Click Load Log to view recent mail activity</pre>
</div>`;
}
window.adminViewMailQueue = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
Nova.modal('Mail Queue', `<pre style="background:var(--bg);padding:1rem;font-size:.8rem;overflow:auto;max-height:400px">${r?.data?.output || 'Queue is empty'}</pre>`);
const out = r?.data?.output || 'Queue is empty';
const el = document.getElementById('ms-queue-out');
if (el) el.innerHTML = `<pre style="margin:0">${Nova.escHtml(out)}</pre>`;
else Nova.modal('Mail Queue', `<pre style="background:var(--bg);padding:1rem;font-size:.8rem;overflow:auto;max-height:400px">${Nova.escHtml(out)}</pre>`);
};
// ── FTP Server ────────────────────────────────────────────────────────────
@@ -2606,23 +2710,36 @@ ${dbs.map(d=>`<tr>
const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd');
const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD');
const status = svcs[svcName] || 'unknown';
const ftpR = await Nova.api('ftp','list',{params:{account_id:0}});
const ftpAccts = ftpR?.data || [];
return `
<div class="card">
<div class="page-header mb-2"><h2 class="page-title">FTP Server</h2></div>
<div class="card mb-3">
<div class="card-header">
<span class="card-title">FTP Server (${label})</span>
<span data-svc-status="${svcName}">${Nova.badge(status, status==='active'?'green':'red')}</span>
<div style="display:flex;gap:.5rem;margin-left:auto">
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')">Restart</button>
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">Reload</button>
<span class="card-title">${label}</span>
${Nova.badge(status, status==='active'?'green':'red')}
<div style="display:flex;gap:.35rem;margin-left:auto">
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')"> Restart</button>
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')"> Reload</button>
</div>
</div>
<div style="padding:1.25rem">
<div style="color:var(--muted);font-size:.85rem">
<p>Active FTP server: <strong>${label}</strong> change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.</p>
<p style="margin-top:.5rem">FTP connections use SFTP on port 22 or passive FTP on ports 20/21.</p>
<p style="margin-top:.5rem">Per-account FTP management is available in each account's FTP page.</p>
</div>
<div class="card-body" style="font-size:.85rem;color:var(--text-muted)">
Active server: <strong>${label}</strong> change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.
Passive FTP ports 20/21 · SFTP on port 22.
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">All FTP Accounts (${ftpAccts.length})</span></div>
${ftpAccts.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Account</th><th>Directory</th><th>Permissions</th></tr></thead><tbody>
${ftpAccts.map(f=>`<tr>
<td><strong>${Nova.escHtml(f.username)}</strong></td>
<td style="font-size:.82rem">${Nova.escHtml(f.account_domain||String(f.account_id)||'—')}</td>
<td style="font-size:.75rem;font-family:monospace">${Nova.escHtml(f.home_dir||'—')}</td>
<td>${Nova.badge(f.permissions||'rw','blue')}</td>
</tr>`).join('')}
</tbody></table></div>`
: '<div class="card-body text-muted">No FTP accounts yet — created from each account&#39;s FTP page.</div>'}
</div>`;
}
@@ -4047,16 +4164,21 @@ async function docker() {
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
</div>
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
<button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
</div>
<div id="docker-tab-content"><div class="loading">Loading</div></div>`;
}
async function dockerLoadTab(tab) {
// Refresh without clearing the list first (keeps current content visible while loading)
async function dockerLoadTabKeep(tab) {
await dockerLoadTab(tab, true);
}
async function dockerLoadTab(tab, keepContent = false) {
const tc = document.getElementById('docker-tab-content');
if (!tc) return;
tc.innerHTML = '<div class="loading">Loading…</div>';
if (!keepContent) tc.innerHTML = '<div class="loading">Loading…</div>';
if (tab === 'containers') {
const r = await Nova.api('docker', 'containers');
@@ -4070,7 +4192,7 @@ ${rows.length === 0 ? '<div class="card"><div class="card-body text-muted" style
<div style="overflow-x:auto"><table class="table"><thead><tr>
<th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th>
</tr></thead><tbody>
${rows.map(c => `<tr>
${rows.map(c => `<tr data-cid="${Nova.escHtml(c.container_id||'')}">
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
@@ -4143,11 +4265,29 @@ ${stacks.map(s=>`<tr>
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
</td>
</tr>`).join('')}
</tbody></table></div>`}`;
} else if (tab === 'catalog') {
const r = await Nova.api('docker', 'catalog');
const catalog = r?.data?.catalog || {};
tc.innerHTML = `
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
${Object.entries(catalog).map(([key,app])=>`
<div class="card" style="transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
<div class="card-body" style="text-align:center;padding:1.5rem">
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
</div>
</div>`).join('')}
</div>`;
} else if (tab === 'quotas') {
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const users = r?.data || [];
@@ -4165,16 +4305,67 @@ ${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
}
}
window.dockerContainerAct = async (cid, action) => {
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('containers');
window.dockerAdminLaunchApp = async (preselect) => {
const catRes = await Nova.api('docker', 'catalog');
const catalog = catRes?.data?.catalog || {};
const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const accounts = acctRes?.data || [];
const appOpts = Object.entries(catalog).map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).join('');
window.dockerAdminUpdateParams = (key) => {
const app = catalog[key]; if (!app) return;
const tc = document.getElementById('dal-params'); if (!tc) return;
tc.innerHTML = (app.params||[]).map(p=>`
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
};
const ov = Nova.modal('Launch App (Admin)',
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
<div id="dal-params"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
);
dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
window.dockerAdminLaunchSubmit = async () => {
const appKey = document.getElementById('dal-app').value;
const accountId = parseInt(document.getElementById('dal-account').value);
const app = catalog[appKey]; if (!app) return;
const params = {};
(app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
const missing = (app.params||[]).filter(p => p.required && !params[p.key]);
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
ov.remove();
Nova.toast(`Launching ${app.name}`, 'info', 15000);
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('stacks');
};
};
window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
window.dockerContainerAct = async (cid, action) => {
// Optimistic UI — update the row immediately so user sees feedback
const row = document.querySelector(`tr[data-cid="${cid}"]`);
if (row) {
const badge = row.querySelector('.badge');
if (badge) { badge.textContent = action === 'stop' ? 'stopping…' : action === 'start' ? 'starting…' : 'restarting…'; badge.className = 'badge badge-yellow'; }
row.querySelectorAll('button').forEach(b => b.disabled = true);
}
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
// Reload tab to show real status (don't clear first — keep current list visible)
if (r?.success || r !== null) dockerLoadTabKeep('containers');
};
window.dockerRemove = (cid) => Nova.confirm('Force remove this container?', async () => {
const row = document.querySelector(`tr[data-cid="${cid}"]`);
if (row) row.style.opacity = '0.4';
const r = await Nova.api('docker', 'container-remove', { method: 'POST', body: { container_id: cid, force: true } });
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('containers');
if (r?.success || r !== null) dockerLoadTabKeep('containers');
}, true);
window.dockerLogs = async (cid, name) => {
@@ -4183,10 +4374,10 @@ window.dockerLogs = async (cid, name) => {
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(logs)}</pre>`);
};
window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => {
const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
window.dockerImgRemove = (id) => Nova.confirm('Remove this image? Stop any containers using it first.', async () => {
const r = await Nova.api('docker', 'image-remove', { method: 'POST', body: { image_id: id } });
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('images');
dockerLoadTabKeep('images');
}, true);
window.dockerPullModal = () => {
@@ -4243,6 +4434,13 @@ window.dockerStackAct = async (id, action) => {
}
};
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
Nova.toast('Reinstalling stack…', 'info', 15000);
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
if (r?.success) dockerLoadTab('stacks');
}, true);
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
@@ -4421,7 +4619,21 @@ async function serverOptions() {
<button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button>
<div id="so-ns-results" style="margin-top:1rem"></div>
</div>
</div>`;
</div>
<!-- Default Index Page Template (#39) -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><span class="card-title">Default Index Page Template</span></div>
<div class="card-body">
<p class="text-muted" style="font-size:.85rem;margin-bottom:.75rem">
HTML shown when a new hosting account is created. Use <code>{domain}</code> and <code>{username}</code> as placeholders. Leave blank to use the built-in styled template.
</p>
<textarea id="so-index-tpl" class="form-control" rows="6" style="font-family:monospace;font-size:.82rem;margin-bottom:.75rem"
placeholder="Leave blank for built-in template...">${Nova.escHtml(opts.default_index_template||'')}</textarea>
<button class="btn btn-primary btn-sm" onclick="soSaveIndexTemplate()">Save Template</button>
<button class="btn btn-ghost btn-sm" style="margin-left:.5rem" onclick="document.getElementById('so-index-tpl').value=''">Reset to Default</button>
</div>
</div>`;
}
window.soSave = (key, inputId, label) => {
@@ -4492,6 +4704,12 @@ window.soSaveNS = async () => {
Nova.toast('Nameservers saved', 'success');
};
window.soSaveIndexTemplate = async () => {
const tpl = document.getElementById('so-index-tpl')?.value || '';
const res = await Nova.api('system','save-option',{method:'POST',body:{key:'default_index_template',value:tpl}});
Nova.toast(res?.success ? 'Default template saved' : 'Save failed', res?.success?'success':'error');
};
window.soCheckNS = async () => {
const tc = document.getElementById('so-ns-results');
if (!tc) return;
@@ -4508,3 +4726,59 @@ ${results.map(z=>`<tr>
</tr>`).join('')}
</tbody></table></div>`;
};
// ══ ADMIN SUBDOMAINS PAGE ═════════════════════════════════════════════════
window.adminSubdomains = async function() {
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
document.querySelector('[data-page="subdomains"]')?.classList.add('active');
document.getElementById('page-title').textContent = 'All Subdomains';
document.getElementById('page-content').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">All subdomains across all hosting accounts.</p>
</div>
<div class="card"><div id="admin-sub-list"><div class="loading">Loading</div></div></div>`;
const el = document.getElementById('admin-sub-list');
const res = await Nova.api('accounts','list',{params:{per_page:200}});
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
let rows = [];
for (const acct of res.data) {
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
if (!dr?.success) continue;
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
}
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
<td>${d.ssl_enabled?Nova.badge('SSL','green'):'&mdash;'}</td>
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
</tbody></table>`;
};
// ══ ADMIN PARKED DOMAINS PAGE ═════════════════════════════════════════════
window.adminParked = async function() {
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
document.querySelector('[data-page="parked-domains"]')?.classList.add('active');
document.getElementById('page-title').textContent = 'Parked Domains';
document.getElementById('page-content').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">All parked/alias domains across all accounts.</p>
</div>
<div class="card"><div id="admin-park-list"><div class="loading">Loading</div></div></div>`;
const el = document.getElementById('admin-park-list');
const res = await Nova.api('accounts','list',{params:{per_page:200}});
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
let rows = [];
for (const acct of res.data) {
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
if (!dr?.success) continue;
const main = dr.data.find(d=>d.type==='main');
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
}
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
</tbody></table>`;
};
+42 -1
View File
@@ -56,7 +56,14 @@ window.Nova = (() => {
return { success: false, message: 'Network error — check your connection' };
}
_barDone();
if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; }
if (res.status === 401) {
const text401 = await res.text();
try {
const j = JSON.parse(text401);
if (endpoint === 'auth' && action === 'login') return j;
return { success: false, message: j.message || 'Session expired — please log in again' };
} catch { return { success: false, message: 'Session expired — please log in again' }; }
}
if (res.status === 429) {
const reset = res.headers.get('X-RateLimit-Reset');
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
@@ -214,6 +221,40 @@ window.Nova = (() => {
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
})();
// #48 Collapsible sidebar nav — shared across all panels
// Exported as window.Nova.initCollapsibleNav so panel JS can call it after dynamic nav render
function _initCollapsibleNav() {
const STORE = 'ncpx_nav_collapsed';
const state = JSON.parse(localStorage.getItem(STORE) || '{}');
document.querySelectorAll('.sidebar-section').forEach(section => {
const label = section.querySelector('.sidebar-section-label');
if (!label || label.querySelector('.nav-chevron')) return; // already initialized
const chevron = document.createElement('i');
chevron.className = 'nav-chevron';
chevron.textContent = '▼';
label.appendChild(chevron);
const key = label.textContent.replace('▼','').trim();
if (state[key]) section.classList.add('collapsed');
const hasActive = section.querySelector('.sidebar-link.active');
if (hasActive) section.classList.remove('collapsed');
label.addEventListener('click', () => {
if (section.querySelector('.sidebar-link.active')) return;
section.classList.toggle('collapsed');
state[key] = section.classList.contains('collapsed');
localStorage.setItem(STORE, JSON.stringify(state));
});
});
}
// Run on DOMContentLoaded for admin panel (static nav), expose globally for dynamic panels
document.addEventListener('DOMContentLoaded', _initCollapsibleNav);
window._initCollapsibleNav = _initCollapsibleNav;
// #26 Mobile sidebar toggle — shared across all panels
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('sidebar-toggle');
+59
View File
@@ -325,6 +325,11 @@ const rNavGroups = [
{ id: 'packages', label: 'Packages',
svg: '<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"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
]},
{ id: 'subdomains', label: 'Subdomains',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
{ id: 'parked-domains', label: 'Parked Domains',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
]},
{ label: 'DNS', items: [
{ id: 'dns', label: 'DNS Zones',
svg: '<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>' },
@@ -361,9 +366,12 @@ function renderRNav() {
document.getElementById('sidebar-overlay')?.classList.remove('open');
document.body.style.overflow = '';
}
const extras = {'subdomains': window.resellerSubdomains, 'parked-domains': window.resellerParked};
if (extras[link.dataset.page]) { extras[link.dataset.page](); _rActivePage = link.dataset.page; return; }
resellerNav(link.dataset.page);
});
});
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
}
window.resellerNav = (page) => {
@@ -698,3 +706,54 @@ window.rWlSave = async () => {
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
r?.success ? 'success' : 'error');
};
// ══ RESELLER SUBDOMAINS PAGE ══════════════════════════════════════════════
window.resellerSubdomains = async function() {
document.getElementById('page-title').textContent = 'Subdomains';
document.getElementById('page-content').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">Subdomains across your customers' accounts.</p>
</div>
<div class="card"><div id="rsub-list"><div class="loading">Loading</div></div></div>`;
const el = document.getElementById('rsub-list');
const res = await Nova.api('accounts','list',{params:{per_page:200}});
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
let rows = [];
for (const acct of res.data) {
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
if (!dr?.success) continue;
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
}
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
<td>${d.ssl_enabled?'<span class="badge badge-green">SSL</span>':'—'}</td>
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
</tbody></table>`;
};
// ══ RESELLER PARKED DOMAINS PAGE ══════════════════════════════════════════
window.resellerParked = async function() {
document.getElementById('page-title').textContent = 'Parked Domains';
document.getElementById('page-content').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">Parked domains across your customers' accounts.</p>
</div>
<div class="card"><div id="rpark-list"><div class="loading">Loading</div></div></div>`;
const el = document.getElementById('rpark-list');
const res = await Nova.api('accounts','list',{params:{per_page:200}});
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
let rows = [];
for (const acct of res.data) {
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
if (!dr?.success) continue;
const main = dr.data.find(d=>d.type==='main');
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
}
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
</tbody></table>`;
};
+272 -4
View File
@@ -937,8 +937,13 @@ const navGroups = [
svg: '<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>' },
]},
{ label: 'Hosting', items: [
{ id: 'domains', label: 'Domains',
{ id: 'domains', label: 'Domains',
svg: '<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>' },
{ id: 'subdomains', label: 'Subdomains',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
{ id: 'parked-domains', label: 'Parked Domains',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
{ id: 'email', label: 'Email',
svg: '<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>' },
{ id: 'databases', label: 'Databases',
@@ -965,6 +970,8 @@ const navGroups = [
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
]},
{ label: 'Account', items: [
{ id: 'account-settings', label: 'Settings',
svg: '<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>' },
{ id: 'change-password', label: 'Change Password',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
]},
@@ -985,7 +992,7 @@ function renderNav() {
</a>`).join('')}
</div>`).join('');
nav.querySelectorAll('[data-page]').forEach(link => {
nav.querySelectorAll("[data-page]").forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
if (window.innerWidth <= 768) {
@@ -996,12 +1003,19 @@ function renderNav() {
userNav(link.dataset.page);
});
});
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
}
window.userNav = (page) => {
_activePage = page;
renderNav();
const allItems = navGroups.flatMap(g => g.items);
// Extra pages not in nav groups
const extraPages = {
'account-settings': accountSettings,
'subdomains': userSubdomains,
'parked-domains': userParked,
};
const item = allItems.find(n => n.id === page);
const titleEl = document.getElementById('page-title');
if (titleEl && item) titleEl.textContent = item.label;
@@ -1076,9 +1090,10 @@ async function dockerPage(el) {
<div class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
</div>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button class="btn btn-sm ${_uDockerTab==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button>
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="uDockerUninstallAll()">Remove All My Apps</button>
</div>
<div id="udocker-content"><div class="loading">Loading</div></div>`;
@@ -1103,6 +1118,18 @@ async function dockerPage(el) {
window._uDockerTab = 'my-apps';
window.uDockerUninstallAll = () => Nova.confirm(
'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
async () => {
Nova.loading('Removing all your Docker apps…');
const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
Nova.loadingDone();
Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) await uDockerReloadStacks();
},
true
);
async function uDockerReloadStacks() {
const r = await Nova.api('docker', 'stacks');
window._uDockerStacks = r?.data?.stacks || [];
@@ -1142,6 +1169,7 @@ ${stacks.map(s=>`<tr>
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
</td>
</tr>`).join('')}
@@ -1180,10 +1208,22 @@ window.uStackLogs = async (stackId, name) => {
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
};
window.uStackReinstall = (stackId, name) => Nova.confirm(
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
async () => {
Nova.loading(`Reinstalling ${name}`);
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
Nova.loadingDone();
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
if (r?.success) await uDockerReloadStacks();
},
true
);
window.uStackRemove = async (stackId, name) => {
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
Nova.loading('Removing app…');
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
Nova.loadingDone();
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) await uDockerReloadStacks();
@@ -1248,3 +1288,231 @@ document.addEventListener('DOMContentLoaded', async () => {
renderNav();
window.userNav('dashboard');
});
// ══ SUBDOMAINS PAGE ════════════════════════════════════════════════════════
async function userSubdomains() {
Nova.loadPage('subdomains', 'Subdomains', `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">Create subdomains on your hosted domains.</p>
<button class="btn btn-primary btn-sm" onclick="addSubdomain()">+ Add Subdomain</button>
</div>
<div class="card"><div id="subdomains-list"><div class="loading">Loading</div></div></div>`);
loadSubdomainsList();
}
async function loadSubdomainsList() {
const el = document.getElementById('subdomains-list');
if (!el) return;
const res = await Nova.api('domains', 'list');
if (!res?.success) { el.innerHTML = '<div class="empty">No domains found</div>'; return; }
const rows = res.data.filter(d => d.type === 'subdomain');
if (!rows.length) {
el.innerHTML = '<div class="empty">No subdomains yet. Click <strong>+ Add Subdomain</strong> to create one.</div>';
return;
}
el.innerHTML = `<table class="table"><thead><tr>
<th>Subdomain</th><th>Document Root</th><th>SSL</th><th>Created</th><th>Actions</th>
</tr></thead><tbody>${rows.map(d => `<tr>
<td><strong>${d.domain}</strong></td>
<td style="font-size:.78rem;color:var(--text-muted)">${d.document_root||'—'}</td>
<td>${d.ssl_enabled ? Nova.badge('SSL','green') : '<span class="text-muted" style="font-size:.78rem">None</span>'}</td>
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
</tr>`).join('')}</tbody></table>`;
}
window.userSubdomains = userSubdomains;
window.addSubdomain = () => {
Nova.modal('Add Subdomain', `
<div class="form-group">
<label class="form-label">Subdomain prefix</label>
<input id="sd-prefix" class="form-control" placeholder="e.g. blog">
<small class="text-muted">Creates <em>blog.yourdomain.com</em></small>
</div>`, `<button class="btn btn-primary" onclick="submitSubdomain()">Create</button>`);
};
window.submitSubdomain = async () => {
const sub = document.getElementById('sd-prefix')?.value?.trim();
if (!sub) { Nova.toast('Enter a subdomain prefix','error'); return; }
const res = await Nova.api('domains', 'add-subdomain', { method: 'POST', body: { subdomain: sub } });
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadSubdomainsList(); }
else Nova.toast(res?.message||'Failed','error');
};
window.removeDomainEntry = (id, domain) => {
Nova.confirm(`Remove ${domain}? This will delete its vhost and directory.`, async () => {
const res = await Nova.api('domains','remove',{method:'POST',body:{id}});
if (res?.success) { Nova.toast('Removed','success'); loadSubdomainsList(); loadParkedList(); }
else Nova.toast(res?.message||'Failed','error');
}, true);
};
// ══ PARKED DOMAINS PAGE ════════════════════════════════════════════════════
async function userParked() {
Nova.loadPage('parked-domains', 'Parked Domains', `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p class="text-muted" style="margin:0">Parked domains point additional domains to your account's main content.</p>
<button class="btn btn-primary btn-sm" onclick="addParked()">+ Park Domain</button>
</div>
<div class="card"><div id="parked-list"><div class="loading">Loading</div></div></div>`);
loadParkedList();
}
async function loadParkedList() {
const el = document.getElementById('parked-list');
if (!el) return;
const res = await Nova.api('domains','list');
if (!res?.success) { el.innerHTML = '<div class="empty">No domains</div>'; return; }
const rows = res.data.filter(d => d.type === 'parked' || d.type === 'alias');
const main = res.data.find(d => d.type === 'main');
if (!rows.length) {
el.innerHTML = `<div class="empty">No parked domains yet. Click <strong>+ Park Domain</strong> to add one.</div>`;
return;
}
el.innerHTML = `<table class="table"><thead><tr>
<th>Parked Domain</th><th>Points To</th><th>Created</th><th>Actions</th>
</tr></thead><tbody>${rows.map(d=>`<tr>
<td><strong>${d.domain}</strong></td>
<td style="font-size:.78rem;color:var(--text-muted)">${main?.domain||'—'}</td>
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
</tr>`).join('')}</tbody></table>`;
}
window.userParked = userParked;
window.addParked = () => {
Nova.modal('Park a Domain', `
<div class="form-group">
<label class="form-label">Domain name</label>
<input id="pd-domain" class="form-control" placeholder="e.g. example.com">
<small class="text-muted">This domain will serve the same content as your primary domain.</small>
</div>`, `<button class="btn btn-primary" onclick="submitParked()">Park Domain</button>`);
};
window.submitParked = async () => {
const domain = document.getElementById('pd-domain')?.value?.trim();
if (!domain) { Nova.toast('Enter a domain','error'); return; }
const res = await Nova.api('domains','add-alias',{method:'POST',body:{domain}});
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadParkedList(); }
else Nova.toast(res?.message||'Failed','error');
};
// ══ #38 ACCOUNT SETTINGS PAGE ═════════════════════════════════════════════
async function accountSettings(el) {
Nova.loadPage('account-settings', 'Account Settings', `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem" id="acct-settings-grid">
<div class="loading" style="grid-column:1/-1">Loading</div>
</div>`);
const [usageRes, phpRes] = await Promise.all([
Nova.api('stats', 'account'),
Nova.api('php', 'config'),
]);
const u = usageRes?.data || {};
const p = {};
const a = { username: u.username || '—', domain: u.domain || '—',
php_version: phpRes?.data?.php_version || '8.3',
status: 'active', created_at: '' };
const el2 = document.getElementById('acct-settings-grid');
if (!el2) return;
el2.innerHTML = `
<!-- Account Info -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><h3 class="card-title">Account Information</h3></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.75rem;padding:.75rem 0">
${[
['Username', a.username||'—'],
['Primary Domain', a.domain||'—'],
['Package', p.name||'Default'],
['Status', a.status ? Nova.badge(a.status, a.status==='active'?'green':'yellow') : '—'],
['PHP Version', a.php_version||'8.3'],
['Created', (a.created_at||'').split('T')[0]||'—'],
].map(([k,v])=>`<div style="background:var(--bg3);border-radius:6px;padding:.75rem">
<div style="font-size:.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.25rem">${k}</div>
<div style="font-weight:600;font-size:.9rem">${v}</div>
</div>`).join('')}
</div>
</div>
<!-- Resource Usage -->
<div class="card">
<div class="card-header"><h3 class="card-title">Resource Usage</h3></div>
${[
['Disk', u.disk_mb||0, u.disk_limit, 'MB'],
['Email', u.emails||0, u.email_limit, 'accounts'],
['Databases', u.databases||0, u.db_limit, 'databases'],
['FTP', u.ftp||0, u.ftp_limit, 'accounts'],
['Domains', u.domains||0, u.domain_limit, 'domains'],
].map(([name,used,limit,unit])=>{
const pct = limit>0 ? Math.min(100,Math.round(used/limit*100)) : 0;
const col = pct>90?'var(--red)':pct>70?'var(--yellow)':'var(--primary)';
return `<div style="margin-bottom:.75rem">
<div style="display:flex;justify-content:space-between;font-size:.8rem;margin-bottom:.3rem">
<span>${name}</span>
<span style="color:var(--text-muted)">${used}${limit>0?` / ${limit} ${unit}`:` ${unit}`}</span>
</div>
${limit>0?`<div style="height:5px;background:var(--bg3);border-radius:3px">
<div style="height:100%;width:${pct}%;background:${col};border-radius:3px;transition:width .3s"></div>
</div>`:''}
</div>`;
}).join('')}
</div>
<!-- PHP Settings -->
<div class="card">
<div class="card-header"><h3 class="card-title">PHP Configuration</h3></div>
<div style="display:grid;gap:.75rem;padding:.25rem 0">
<div>
<label class="form-label">PHP Version</label>
<select id="as-php-ver" class="form-control form-control-sm">
${['8.3','8.2','8.1','8.0','7.4'].map(v=>`<option value="${v}"${(a.php_version||'8.3')===v?' selected':''}>${v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Memory Limit</label>
<select id="as-mem" class="form-control form-control-sm">
${['128M','256M','512M','1G'].map(v=>`<option value="${v}"${v==='256M'?' selected':''}>${ v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Max Upload Size</label>
<select id="as-upload" class="form-control form-control-sm">
${['32M','64M','128M','256M'].map(v=>`<option value="${v}"${v==='64M'?' selected':''}>${ v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Max Execution Time (sec)</label>
<select id="as-exec" class="form-control form-control-sm">
${['30','60','120','300'].map(v=>`<option value="${v}"${v==='30'?' selected':''}>${ v}s</option>`).join('')}
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="saveAccountPHP()">Save PHP Settings</button>
</div>
</div>
<!-- Security & Access -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><h3 class="card-title">Security & Access</h3></div>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<button class="btn btn-sm" onclick="userNav('change-password')">🔑 Change Password</button>
<button class="btn btn-sm" onclick="userNav('ssl')">🔒 SSL / TLS Certificates</button>
<button class="btn btn-sm" onclick="userNav('twofa')">📱 Two-Factor Auth</button>
</div>
</div>
`;
}
window.accountSettings = accountSettings;
window.saveAccountPHP = async () => {
const body = {
php_version: document.getElementById('as-php-ver')?.value,
memory_limit: document.getElementById('as-mem')?.value,
upload_max_filesize: document.getElementById('as-upload')?.value,
post_max_size: document.getElementById('as-upload')?.value,
max_execution_time: parseInt(document.getElementById('as-exec')?.value),
};
Nova.loading('Saving…');
const res = await Nova.api('php', 'update-config', { method: 'POST', body });
Nova.loadingDone();
if (res?.success) Nova.toast('PHP settings saved', 'success');
else Nova.toast(res?.message || 'Failed', 'error');
};
+94
View File
@@ -0,0 +1,94 @@
<?php
// NovaCPX Reseller Panel — port 8881
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
require_once dirname(__DIR__) . '/_branding.php';
$_pname = novacpx_panel_name('NovaCPX');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="NovaCPX Reseller Panel — manage your reseller hosting accounts, packages, clients, and branding from one dashboard.">
<meta name="keywords" content="reseller hosting panel, reseller control panel, manage hosting clients, white label hosting, NovaCPX reseller">
<meta name="robots" content="noindex, nofollow">
<title><?= $_pname ?> — Reseller</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/nova-favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
<?php novacpx_branding_head() ?>
</head>
<body>
<div class="panel-layout" id="main-layout" style="display:none">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#rlg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#rlg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#rlg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="rlg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="rlg2" 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"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">Reseller</small>
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
</span>
</div>
<nav id="sidebar-nav"></nav>
<div class="sidebar-user">
<div class="sidebar-user-info">
<div class="avatar" id="user-avatar">R</div>
<div><div class="user-name" id="user-name">Reseller</div><div class="user-role">Reseller Account</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">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div class="topbar-title" id="page-title">Reseller Dashboard</div>
</header>
<div class="page-content" id="page-content"></div>
</div>
</div>
<!-- Auth guard / Inline login -->
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
<div style="width:100%;max-width:400px;padding:1.5rem">
<div style="text-align:center;margin-bottom:1.5rem">
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
<circle cx="20" cy="20" r="18" stroke="url(#rlga)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#rlgb)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#rlgb)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="rlga" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
</div>
<div class="card">
<div class="card-body">
<div id="li-err" class="alert alert-error" style="display:none"></div>
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
<button type="submit" class="btn btn-primary btn-full">Sign In to Reseller Panel</button>
</form>
</div>
</div>
</div>
</div>
<script>
window.NOVACPX_BRANDING = <?= json_encode([
'panel_name' => novacpx_panel_name('NovaCPX'),
'support_email' => novacpx_get_branding()['support_email'] ?? null,
'support_url' => novacpx_get_branding()['support_url'] ?? null,
'hide_powered_by' => !novacpx_powered_by(),
]) ?>;
</script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/reseller.js<?= $_v('/assets/js/reseller.js') ?>"></script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
<?php
// NovaCPX User Panel — End-user hosting dashboard
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
require_once dirname(__DIR__) . '/_branding.php';
$_pname = novacpx_panel_name('NovaCPX');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Your hosting control panel — manage your website, domains, email accounts, databases, FTP, SSL certificates, and files in one place.">
<meta name="keywords" content="hosting control panel, manage website, email hosting, domain management, database management, SSL certificate, FTP, web hosting dashboard">
<meta name="robots" content="noindex, nofollow">
<title><?= $_pname ?> — My Hosting</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/nova-favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
<?php novacpx_branding_head() ?>
<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="main-layout" style="display:none">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<?= novacpx_logo_html('<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"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">My Hosting</small>
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
</span>
</div>
<nav id="sidebar-nav"></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">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<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;background:var(--bg)">
<div style="width:100%;max-width:400px;padding:1.5rem">
<div style="text-align:center;margin-bottom:1.5rem">
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
<circle cx="20" cy="20" r="18" stroke="url(#ulg3)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg4)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#ulg4)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="ulg3" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
</div>
<div class="card">
<div class="card-body">
<div id="li-err" class="alert alert-error" style="display:none"></div>
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
<button type="submit" class="btn btn-primary btn-full">Sign In</button>
</form>
</div>
</div>
</div>
</div>
<script>
window.NOVACPX_BRANDING = <?= json_encode([
'panel_name' => novacpx_panel_name('NovaCPX'),
'support_email' => novacpx_get_branding()['support_email'] ?? null,
'support_url' => novacpx_get_branding()['support_url'] ?? null,
'hide_powered_by' => !novacpx_powered_by(),
]) ?>;
</script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/user.js<?= $_v('/assets/js/user.js') ?>"></script>
<!-- user.js boots via DOMContentLoaded and handles all auth/init -->
</body>
</html>
Executable
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# NovaCPX Uninstaller
# Backs up everything, then cleanly removes all NovaCPX components.
# Usage: bash uninstall.sh [--yes]
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; }
[[ $EUID -ne 0 ]] && fail "Run as root"
SKIP_CONFIRM="${1:-}"
DB_PATH=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('database','path',fallback='/var/lib/novacpx/panel.db'))" 2>/dev/null || echo "/var/lib/novacpx/panel.db")
BACKUP_DIR="/tmp/novacpx-uninstall-backup-$(date +%Y%m%d-%H%M%S)"
BACKUP_ARCHIVE="${BACKUP_DIR}.tar.gz"
echo ""
echo -e "${BOLD}NovaCPX Uninstaller${NC}"
echo "This will completely remove NovaCPX and all hosted accounts."
echo ""
# ── STEP 1: Create full backup ─────────────────────────────────────────────
step "Creating full backup before removal"
mkdir -p "$BACKUP_DIR"/{db,configs,accounts,logs,certs,nginx,systemd,cron}
# Database
[[ -f "$DB_PATH" ]] && {
cp "$DB_PATH" "$BACKUP_DIR/db/panel.db"
log "Database backed up"
}
# All account home directories
if [[ -f "$DB_PATH" ]]; then
while IFS='|' read -r user home; do
[[ -d "$home" ]] && cp -a "$home" "$BACKUP_DIR/accounts/" 2>/dev/null && log "Account: $user ($home)"
done < <(sqlite3 "$DB_PATH" "SELECT username, home_dir FROM accounts;" 2>/dev/null)
fi
# Config files
[[ -d /etc/novacpx ]] && cp -a /etc/novacpx "$BACKUP_DIR/configs/" && log "NovaCPX configs"
[[ -d /var/lib/novacpx ]] && cp -a /var/lib/novacpx "$BACKUP_DIR/db/var-lib-novacpx" 2>/dev/null
# Nginx vhosts
cp /etc/nginx/sites-available/novacpx-*.conf "$BACKUP_DIR/nginx/" 2>/dev/null || true
log "Nginx vhosts"
# SSL certs per account
[[ -d /etc/novacpx/ssl/accounts ]] && cp -a /etc/novacpx/ssl/accounts "$BACKUP_DIR/certs/" && log "SSL certs"
# Systemd units
cp /etc/systemd/system/novacpx*.service "$BACKUP_DIR/systemd/" 2>/dev/null || true
log "Systemd units"
# Cron
cp /etc/cron.d/novacpx "$BACKUP_DIR/cron/" 2>/dev/null || true
log "Cron jobs"
# Logs
[[ -d /var/log/novacpx ]] && cp -a /var/log/novacpx "$BACKUP_DIR/logs/" && log "Logs"
# DNS zones
[[ -d /var/cache/bind ]] && cp -a /var/cache/bind "$BACKUP_DIR/configs/bind-cache" 2>/dev/null || true
[[ -f /etc/bind/named.conf.local ]] && cp /etc/bind/named.conf.local "$BACKUP_DIR/configs/" 2>/dev/null || true
# Mail config
[[ -d /etc/postfix ]] && cp -a /etc/postfix "$BACKUP_DIR/configs/postfix" 2>/dev/null || true
[[ -d /etc/dovecot ]] && cp -a /etc/dovecot "$BACKUP_DIR/configs/dovecot" 2>/dev/null || true
# Compress backup — abort if tar fails rather than silently delete unbackedup files
tar -czf "$BACKUP_ARCHIVE" -C "$(dirname $BACKUP_DIR)" "$(basename $BACKUP_DIR)" || {
echo -e "${RED}[✗]${NC} Backup archive creation FAILED. Uninstall aborted to protect your data."
echo " Staging dir preserved at: $BACKUP_DIR"
exit 1
}
rm -rf "$BACKUP_DIR"
BACKUP_SIZE=$(du -sh "$BACKUP_ARCHIVE" | cut -f1)
log "Backup archive: $BACKUP_ARCHIVE ($BACKUP_SIZE)"
echo ""
echo -e "${BOLD}Backup complete.${NC}"
echo ""
echo "To download the backup before continuing:"
echo " scp root@$(hostname -I | awk '{print $1}'):${BACKUP_ARCHIVE} ./"
echo ""
echo "Or serve temporarily:"
echo " cd $(dirname $BACKUP_ARCHIVE) && python3 -m http.server 9999 &"
echo " # Download: http://$(hostname -I | awk '{print $1}'):9999/$(basename $BACKUP_ARCHIVE)"
echo ""
if [[ "$SKIP_CONFIRM" != "--yes" ]]; then
read -r -p "Continue with uninstall? This CANNOT be undone. [yes/no]: " CONFIRM
[[ "$CONFIRM" != "yes" ]] && { warn "Aborted."; exit 0; }
fi
# ── STEP 2: Stop all NovaCPX services ────────────────────────────────────
step "Stopping services"
systemctl stop novacpx-web 2>/dev/null && log "Stopped novacpx-web" || true
systemctl disable novacpx-web 2>/dev/null || true
# ── STEP 3: Remove hosting accounts ──────────────────────────────────────
step "Removing hosting accounts"
if [[ -f "$DB_PATH" ]]; then
while IFS='|' read -r username home_dir; do
[[ -z "$username" ]] && continue
# Remove Linux user and home dir
id "$username" &>/dev/null && userdel -r "$username" 2>/dev/null && log "Removed user: $username" || true
# Remove PHP-FPM pool
rm -f "/etc/php/8.3/fpm/pool.d/${username}.conf" 2>/dev/null || true
# Remove nginx vhost
rm -f "/etc/nginx/sites-available/novacpx-${username}.conf" \
"/etc/nginx/sites-enabled/novacpx-${username}.conf" 2>/dev/null || true
done < <(sqlite3 "$DB_PATH" "SELECT username, home_dir FROM accounts;" 2>/dev/null)
fi
# Also clean any remaining novacpx vhosts
rm -f /etc/nginx/sites-available/novacpx-*.conf \
/etc/nginx/sites-enabled/novacpx-*.conf 2>/dev/null || true
log "Nginx vhosts removed"
# Remove webacct if exists
id webacct &>/dev/null && userdel -r webacct 2>/dev/null || true
# ── STEP 4: Remove PHP-FPM pools ─────────────────────────────────────────
step "Removing PHP-FPM pools"
for f in /etc/php/*/fpm/pool.d/novacpx-*.conf /etc/php/*/fpm/pool.d/webacct.conf; do
[[ -f "$f" ]] && rm -f "$f" && log "Removed pool: $f" || true
done
systemctl reload php8.3-fpm 2>/dev/null || true
# ── STEP 5: Remove systemd units ─────────────────────────────────────────
step "Removing systemd units"
for svc in novacpx-web; do
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
rm -f "/etc/systemd/system/${svc}.service"
rm -rf "/etc/systemd/system/${svc}.service.d"
log "Removed: $svc"
done
systemctl daemon-reload
# ── STEP 6: Remove sudoers ────────────────────────────────────────────────
step "Removing sudoers rules"
for f in novacpx novacpx-firewall novacpx-panel novacpx-services; do
rm -f "/etc/sudoers.d/$f" && log "Removed sudoers: $f" || true
done
# Remove any novacpx entries added to main sudoers
sed -i '/Defaults:www-data !requiretty/d' /etc/sudoers 2>/dev/null || true
# ── STEP 7: Remove cron jobs ──────────────────────────────────────────────
step "Removing cron jobs"
rm -f /etc/cron.d/novacpx && log "Removed /etc/cron.d/novacpx" || true
# Remove root crontab entries added by NovaCPX
crontab -l 2>/dev/null | grep -v "novacpx" | crontab - 2>/dev/null || true
log "Root crontab cleaned"
# ── STEP 8: Remove nginx default override ────────────────────────────────
step "Restoring nginx defaults"
# Restore default site (remove 444 return we added)
cat > /etc/nginx/sites-available/default << 'NGINX'
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm;
server_name _;
location / { try_files $uri $uri/ =404; }
}
NGINX
nginx -t 2>/dev/null && systemctl reload nginx && log "nginx restored"
# ── STEP 9: Remove mail/DNS config added by NovaCPX ──────────────────────
step "Removing mail/DNS config"
# Remove opendkim signing entries for hosted domains
[[ -f /etc/opendkim/signing.table ]] && > /etc/opendkim/signing.table || true
[[ -f /etc/opendkim/key.table ]] && > /etc/opendkim/key.table || true
rm -rf /etc/opendkim/keys/* 2>/dev/null || true
systemctl reload opendkim 2>/dev/null || true
log "DKIM config cleared"
# Remove DNS zones for hosted domains
rm -f /etc/bind/zones/db.novacpx.* 2>/dev/null || true
# Remove novacpx entries from named.conf.local
[[ -f /etc/bind/named.conf.local ]] && \
sed -i '/novacpx/d' /etc/bind/named.conf.local 2>/dev/null || true
systemctl reload named 2>/dev/null || true
log "DNS zones cleared"
# Postfix virtual mailbox maps
[[ -f /etc/postfix/novacpx_virtual_domains ]] && {
rm -f /etc/postfix/novacpx_virtual_domains /etc/postfix/novacpx_virtual_mailbox \
/etc/postfix/novacpx_virtual_aliases 2>/dev/null || true
postmap /etc/postfix/novacpx_* 2>/dev/null || true
systemctl reload postfix 2>/dev/null || true
log "Postfix virtual tables cleared"
}
# ── STEP 10: Remove all NovaCPX files ────────────────────────────────────
step "Removing NovaCPX files"
rm -rf /srv/novacpx && log "Removed /srv/novacpx"
rm -rf /opt/novacpx-src && log "Removed /opt/novacpx-src"
rm -rf /opt/novacpx && log "Removed /opt/novacpx"
rm -rf /var/lib/novacpx && log "Removed /var/lib/novacpx"
rm -rf /var/log/novacpx && log "Removed /var/log/novacpx"
rm -rf /etc/novacpx && log "Removed /etc/novacpx"
rm -f /etc/nginx/htpasswd.webacct 2>/dev/null || true
rm -f /usr/local/bin/novacpx* /usr/local/bin/novacpx-* 2>/dev/null || true
# Remove fail2ban filters
rm -f /etc/fail2ban/filter.d/novacpx-*.conf \
/etc/fail2ban/jail.d/novacpx*.conf 2>/dev/null || true
systemctl reload fail2ban 2>/dev/null || true
log "fail2ban filters removed"
# ── DONE ──────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}NovaCPX has been fully uninstalled.${NC}"
echo ""
echo "Backup archive preserved at: $BACKUP_ARCHIVE"
echo ""
echo "Services still running (not removed, NovaCPX didn't own these):"
echo " nginx, php8.3-fpm, postfix, dovecot, bind9, fail2ban"
echo " (stop/remove them separately if no longer needed)"
echo ""