Plugin UI: Premium filter, URL hash sync; installer DB SQL fallback; loginSystem migrations.

- plugins.html: Premium show filter, #grid?show=&sort=&cat=&q= hash restore, cache 28.03.2026-v3.
- pluginInstaller + plogical/pluginMigrationSQL: migrate fallback via sqlmigrate/mariadb and DROP cleanup on remove.
- loginSystem: initial migration (SeparateDatabaseAndState) for graph compatibility.
- README: Updated 28.03.2026.
This commit is contained in:
master3395
2026-03-28 01:21:37 +01:00
parent 6755d75e2c
commit db24409d0d
6 changed files with 587 additions and 42 deletions

View File

@@ -7,7 +7,7 @@
**Web Hosting Control Panel powered by OpenLiteSpeed**
Fast • Secure • Scalable — Simplify hosting management with style.
**Version**: 2.5.5-dev • **Updated**: January 15, 2026
**Version**: 2.5.5-dev • **Updated**: 28.03.2026
[![GitHub](https://img.shields.io/badge/GitHub-Repo-000?style=flat-square\&logo=github)](https://github.com/usmannasir/cyberpanel)
[![Docs](https://img.shields.io/badge/Docs-Read-green?style=flat-square\&logo=gitbook)](https://cyberpanel.net/KnowledgeBase/)

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Generated for CyberPanel: loginSystem had models but no migrations, which broke
# the global dependency graph (e.g. dockerManager depends on loginSystem.__first__).
#
# Pre-existing panels already have loginSystem_* tables from legacy installs.
# This migration updates Django state only; it does not run DDL.
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name='ACL',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('adminStatus', models.IntegerField(default=0)),
('versionManagement', models.IntegerField(default=0)),
('createNewUser', models.IntegerField(default=0)),
('listUsers', models.IntegerField(default=0)),
('deleteUser', models.IntegerField(default=0)),
('resellerCenter', models.IntegerField(default=0)),
('changeUserACL', models.IntegerField(default=0)),
('createWebsite', models.IntegerField(default=0)),
('modifyWebsite', models.IntegerField(default=0)),
('suspendWebsite', models.IntegerField(default=0)),
('deleteWebsite', models.IntegerField(default=0)),
('createPackage', models.IntegerField(default=0)),
('listPackages', models.IntegerField(default=0)),
('deletePackage', models.IntegerField(default=0)),
('modifyPackage', models.IntegerField(default=0)),
('createDatabase', models.IntegerField(default=1)),
('deleteDatabase', models.IntegerField(default=1)),
('listDatabases', models.IntegerField(default=1)),
('createNameServer', models.IntegerField(default=0)),
('createDNSZone', models.IntegerField(default=1)),
('deleteZone', models.IntegerField(default=1)),
('addDeleteRecords', models.IntegerField(default=1)),
('createEmail', models.IntegerField(default=1)),
('listEmails', models.IntegerField(default=1)),
('deleteEmail', models.IntegerField(default=1)),
('emailForwarding', models.IntegerField(default=1)),
('changeEmailPassword', models.IntegerField(default=1)),
('dkimManager', models.IntegerField(default=1)),
('createFTPAccount', models.IntegerField(default=1)),
('deleteFTPAccount', models.IntegerField(default=1)),
('listFTPAccounts', models.IntegerField(default=1)),
('createBackup', models.IntegerField(default=0)),
('restoreBackup', models.IntegerField(default=0)),
('addDeleteDestinations', models.IntegerField(default=0)),
('scheduleBackups', models.IntegerField(default=0)),
('remoteBackups', models.IntegerField(default=0)),
('manageSSL', models.IntegerField(default=1)),
('hostnameSSL', models.IntegerField(default=0)),
('mailServerSSL', models.IntegerField(default=0)),
('config', models.TextField(default='{}')),
],
),
migrations.CreateModel(
name='Administrator',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('userName', models.CharField(max_length=50, unique=True)),
('password', models.CharField(max_length=200)),
('firstName', models.CharField(default='None', max_length=200)),
('lastName', models.CharField(default='None', max_length=200)),
('email', models.CharField(max_length=50)),
('type', models.IntegerField()),
('owner', models.IntegerField(default=1)),
('token', models.CharField(default='None', max_length=500)),
('api', models.IntegerField(default=0)),
(
'securityLevel',
models.IntegerField(choices=[(0, 'HIGH'), (1, 'LOW')], default=0),
),
('state', models.CharField(default='ACTIVE', max_length=10)),
('initWebsitesLimit', models.IntegerField(default=0)),
('twoFA', models.IntegerField(default=0)),
('secretKey', models.CharField(default='None', max_length=50)),
('config', models.TextField(default='{}')),
('defaultSite', models.IntegerField(default=0)),
(
'acl',
models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.PROTECT,
to='loginSystem.acl',
),
),
],
),
],
),
]

View File

@@ -0,0 +1 @@
# loginSystem migrations package (CyberPanel core)

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""
Plugin install/remove: robust DB migration and teardown for CyberPanel.
- Prefer Django ``migrate`` / ``migrate zero``.
- If the global loader fails or migrate errors, apply ``sqlmigrate`` output and
``--fake``, or drop plugin tables and clean ``django_migrations`` on removal.
Assumes ``manage.py`` / ``CyberCP.settings`` are available under /usr/local/CyberCP.
"""
from __future__ import annotations
import os
import subprocess
def _manage_py() -> str:
return '/usr/local/CyberCP/manage.py'
def _python_executable() -> str:
for candidate in ('/usr/local/CyberCP/bin/python', '/usr/local/CyberCP/bin/python3'):
try:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
except OSError:
continue
return 'python3'
def _django_setup():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CyberCP.settings')
import django
django.setup()
def _db_name() -> str:
_django_setup()
from django.conf import settings
return settings.DATABASES['default']['NAME']
def run_manage(args: list, cwd: str | None = None) -> subprocess.CompletedProcess:
"""Run manage.py with args (e.g. ['migrate', 'contaboAutoSnapshot', '--noinput'])."""
cmd = [_python_executable(), _manage_py()] + args
return subprocess.run(
cmd,
cwd=cwd or '/usr/local/CyberCP',
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=600,
)
def list_plugin_migration_names(plugin_name: str) -> list[str]:
"""Sorted migration names from disk (e.g. 0001_initial), excluding __init__."""
mig_dir = '/usr/local/CyberCP/' + plugin_name + '/migrations'
if not os.path.isdir(mig_dir):
return []
names = []
try:
for fn in os.listdir(mig_dir):
if not fn.endswith('.py') or fn == '__init__.py':
continue
names.append(fn[:-3])
except OSError:
return []
return sorted(names)
def applied_plugin_migrations(plugin_name: str) -> set[str]:
_django_setup()
from django.db import connection
from django.db.migrations.recorder import MigrationRecorder
recorder = MigrationRecorder(connection)
return {name for app, name in recorder.applied_migrations() if app == plugin_name}
def sqlmigrate_text(plugin_name: str, migration_name: str) -> tuple[int, str]:
"""Return (returncode, combined stdout+stderr) for sqlmigrate."""
proc = run_manage(['sqlmigrate', plugin_name, migration_name])
out = (proc.stdout or '') + (proc.stderr or '')
return proc.returncode, out
def _strip_sql_comments(sql: str) -> str:
lines = []
for line in sql.splitlines():
s = line.strip()
if s.startswith('--'):
continue
lines.append(line)
return '\n'.join(lines)
def _execute_multi_sql_mysql(sql: str) -> tuple[bool, str]:
"""Execute multi-statement ``sqlmigrate`` output via ``mariadb`` CLI (socket auth as root)."""
sql = _strip_sql_comments(sql).strip()
if not sql:
return True, ''
db = _db_name()
try:
proc = subprocess.run(
['mariadb', db],
input=sql.encode('utf-8', errors='replace'),
capture_output=True,
timeout=300,
)
err = (proc.stderr or b'').decode('utf-8', errors='replace')[:2000]
if proc.returncode != 0:
return False, err or 'mariadb exited %s' % proc.returncode
return True, ''
except FileNotFoundError:
return False, 'mariadb client not found; install MariaDB client or fix PATH'
except Exception as e:
return False, str(e)[:2000]
def apply_pending_migrations_via_sql_and_fake(plugin_name: str, log) -> bool:
"""
For each on-disk migration not in django_migrations: sqlmigrate + execute, then
``migrate <app> <name> --fake`` so each is recorded.
"""
pending = [m for m in list_plugin_migration_names(plugin_name) if m not in applied_plugin_migrations(plugin_name)]
if not pending:
log(
'SQL fallback: no pending migrations recorded for %s — schema/DB mismatch may remain; '
'check migrate errors above.'
% plugin_name
)
return False
for mig in pending:
rc, out = sqlmigrate_text(plugin_name, mig)
if rc != 0:
log('sqlmigrate %s %s failed (rc=%s): %s' % (plugin_name, mig, rc, out[:800]))
return False
ok, err = _execute_multi_sql_mysql(out)
if not ok:
log('SQL exec failed for %s %s: %s' % (plugin_name, mig, err))
return False
log('Applied raw SQL for %s %s' % (plugin_name, mig))
proc = run_manage(['migrate', plugin_name, mig, '--fake', '--noinput'])
if proc.returncode != 0:
log(
'migrate --fake %s %s failed: %s'
% (plugin_name, mig, (proc.stderr or proc.stdout or '')[:800])
)
return False
return True
def drop_plugin_tables_and_migration_rows(plugin_name: str, log) -> bool:
"""
DROP all tables for this app label (prefix ``pluginName_``) and remove
django_migrations rows. Uses FOREIGN_KEY_CHECKS=0.
"""
_django_setup()
from django.db import connection
db = _db_name()
prefix_a = plugin_name + '_'
prefix_b = plugin_name.lower() + '_'
tables = []
try:
with connection.cursor() as c:
c.execute(
'SELECT table_name FROM information_schema.tables WHERE table_schema = %s '
'AND (table_name LIKE %s OR table_name LIKE %s)',
[db, prefix_a + '%', prefix_b + '%'],
)
tables = [row[0] for row in c.fetchall()]
except Exception as e:
log('list tables for drop failed: %s' % str(e)[:800])
return False
if not tables:
log('No tables matched prefix for %s; cleaning django_migrations only.' % plugin_name)
try:
with connection.cursor() as c:
c.execute('SET FOREIGN_KEY_CHECKS=0')
for t in tables:
safe = t.replace('`', '``')
c.execute('DROP TABLE IF EXISTS `%s`' % safe)
c.execute('SET FOREIGN_KEY_CHECKS=1')
c.execute('DELETE FROM django_migrations WHERE app = %s', [plugin_name])
log('Dropped %s table(s) for %s and removed django_migrations rows.' % (len(tables), plugin_name))
return True
except Exception as e:
log('drop_plugin_tables failed: %s' % str(e)[:800])
return False
def ensure_login_system_migrations_applied(log) -> None:
"""
Apply loginSystem 0001 if present (graph fix). Safe no-op if already applied.
"""
mig_path = '/usr/local/CyberCP/loginSystem/migrations/0001_initial.py'
if not os.path.isfile(mig_path):
return
proc = run_manage(['migrate', 'loginSystem', '--noinput'])
if proc.returncode != 0:
log(
'migrate loginSystem exited %s (panels may need manual fix): %s'
% (proc.returncode, (proc.stderr or proc.stdout or '')[:600])
)

View File

@@ -1378,6 +1378,9 @@
<button type="button" class="sort-btn filter-btn" data-filter="active" id="installedFilterBtnActive" onclick="setInstalledFilter('active')" title="{% trans 'Show only active (enabled) plugins' %}">
<i class="fas fa-power-off"></i> <span class="filter-btn-label">{% trans "Active only" %}</span>
</button>
<button type="button" class="sort-btn filter-btn" data-filter="premium" id="installedFilterBtnPremium" onclick="setInstalledFilter('premium')" title="{% trans 'Show only premium (paid) plugins' %}">
<i class="fas fa-crown"></i> <span class="filter-btn-label">{% trans "Premium" %}</span>
</button>
</div>
</div>
<div class="installed-sort-row">
@@ -1414,7 +1417,7 @@
<!-- Grid View -->
<div id="gridView" class="plugins-grid">
{% for plugin in plugins %}
<div class="plugin-card" data-plugin-name="{{ plugin.name }}" data-plugin-desc="{{ plugin.desc }}" data-plugin-type="{{ plugin.type }}" data-modify-date="{{ plugin.modify_date|default:'0000-00-00 00:00:00' }}" data-installed="{{ plugin.installed|yesno:'true,false' }}" data-enabled="{{ plugin.enabled|yesno:'true,false' }}">
<div class="plugin-card" data-plugin-name="{{ plugin.name }}" data-plugin-desc="{{ plugin.desc }}" data-plugin-type="{{ plugin.type }}" data-modify-date="{{ plugin.modify_date|default:'0000-00-00 00:00:00' }}" data-installed="{{ plugin.installed|yesno:'true,false' }}" data-enabled="{{ plugin.enabled|yesno:'true,false' }}" data-is-paid="{% if plugin.is_paid|default:False|default_if_none:False %}true{% else %}false{% endif %}">
<div class="plugin-header">
<div class="plugin-icon">
{% if plugin.type|lower == "security" %}
@@ -1561,7 +1564,7 @@
</thead>
<tbody>
{% for plugin in plugins %}
<tr data-plugin-name="{{ plugin.name }}" data-plugin-desc="{{ plugin.desc }}" data-plugin-type="{{ plugin.type }}" data-modify-date="{{ plugin.modify_date|default:'0000-00-00 00:00:00' }}" data-installed="{{ plugin.installed|yesno:'true,false' }}" data-enabled="{{ plugin.enabled|yesno:'true,false' }}">
<tr data-plugin-name="{{ plugin.name }}" data-plugin-desc="{{ plugin.desc }}" data-plugin-type="{{ plugin.type }}" data-modify-date="{{ plugin.modify_date|default:'0000-00-00 00:00:00' }}" data-installed="{{ plugin.installed|yesno:'true,false' }}" data-enabled="{{ plugin.enabled|yesno:'true,false' }}" data-is-paid="{% if plugin.is_paid|default:False|default_if_none:False %}true{% else %}false{% endif %}">
<td>
<strong>{{ plugin.name }}</strong>
{% if plugin.freshness_badge %}
@@ -1871,15 +1874,127 @@
</div>
<script>
// Cache-busting version: 2026-03-27-v2 - Modify date: browser-local via modify_timestamp + nb-NO style
// Cache-busting version: 28.03.2026-v3 - Hash sync for installed filters (#grid?show=premium&sort=…)
let storePlugins = [];
let currentFilter = 'all';
let currentCategory = 'all';
let currentSearchQuery = '';
let isSettingHash = false; // Flag to prevent infinite loops
let currentInstalledSort = 'name-asc'; // name-asc, name-desc, type, date-desc, date-asc
let currentInstalledFilter = 'all'; // all, installed, active
let currentInstalledFilter = 'all'; // all, installed, active, premium
let currentInstalledCategory = 'all'; // all, Utility, Security, ...
let applyingInstalledHash = false;
let installedSearchHashDebounce = null;
var INSTALLED_HASH_VALID_VIEWS = ['grid', 'table', 'upgrades', 'store'];
var INSTALLED_HASH_SHOW_VALUES = { all: 1, installed: 1, active: 1, premium: 1 };
var INSTALLED_HASH_SORT_VALUES = { 'name-asc': 1, 'name-desc': 1, 'type': 1, 'date-desc': 1, 'date-asc': 1 };
function parsePluginsHashFragment(raw) {
raw = (raw || '').trim();
var empty = { view: 'grid', show: 'all', sort: 'name-asc', cat: 'all', q: '' };
if (!raw) return empty;
var qIdx = raw.indexOf('?');
var viewPart = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim();
var qs = qIdx >= 0 ? raw.slice(qIdx + 1) : '';
var view = viewPart || 'grid';
if (INSTALLED_HASH_VALID_VIEWS.indexOf(view) === -1) {
view = 'grid';
qs = '';
}
var params = new URLSearchParams(qs);
var show = params.get('show') || 'all';
var sort = params.get('sort') || 'name-asc';
var cat = params.get('cat') || 'all';
var q = params.get('q') || '';
if (!INSTALLED_HASH_SHOW_VALUES[show]) show = 'all';
if (!INSTALLED_HASH_SORT_VALUES[sort]) sort = 'name-asc';
return { view: view, show: show, sort: sort, cat: cat, q: q };
}
function buildPluginsHashString(view) {
if (view === 'store' || view === 'upgrades') {
return '#' + view;
}
var parts = [];
if (currentInstalledFilter && currentInstalledFilter !== 'all') {
parts.push('show=' + encodeURIComponent(currentInstalledFilter));
}
if (currentInstalledSort && currentInstalledSort !== 'name-asc') {
parts.push('sort=' + encodeURIComponent(currentInstalledSort));
}
if (currentInstalledCategory && currentInstalledCategory !== 'all') {
parts.push('cat=' + encodeURIComponent(currentInstalledCategory));
}
var qEl = document.getElementById('installedPluginSearchInput');
var qv = qEl && qEl.value ? String(qEl.value).trim() : '';
if (qv) parts.push('q=' + encodeURIComponent(qv));
if (parts.length) return '#' + view + '?' + parts.join('&');
return '#' + view;
}
function getCurrentInstalledGridOrTableView() {
var gv = document.getElementById('gridView');
var tv = document.getElementById('tableView');
if (gv && gv.style.display === 'grid') return 'grid';
if (tv && tv.style.display === 'block') return 'table';
return null;
}
function updateInstalledPluginsHashIfNeeded() {
if (applyingInstalledHash) return;
var v = getCurrentInstalledGridOrTableView();
if (!v) return;
isSettingHash = true;
var h = buildPluginsHashString(v);
var newUrl = window.location.pathname + window.location.search + h;
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', newUrl);
} else {
window.location.hash = h.replace(/^#/, '');
}
setTimeout(function() { isSettingHash = false; }, 100);
}
function scheduleInstalledSearchHashUpdate() {
if (installedSearchHashDebounce) clearTimeout(installedSearchHashDebounce);
installedSearchHashDebounce = setTimeout(function() {
installedSearchHashDebounce = null;
updateInstalledPluginsHashIfNeeded();
}, 450);
}
function applyInstalledStateFromHash(parsed) {
if (!parsed || (parsed.view !== 'grid' && parsed.view !== 'table')) return;
applyingInstalledHash = true;
try {
currentInstalledFilter = parsed.show;
currentInstalledSort = parsed.sort;
currentInstalledCategory = parsed.cat;
var inp = document.getElementById('installedPluginSearchInput');
if (inp) {
inp.value = parsed.q;
var clr = document.getElementById('installedPluginSearchClear');
if (clr) clr.style.display = parsed.q ? 'block' : 'none';
}
var bar = document.getElementById('installedSortFilterBar');
if (bar) {
bar.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.classList.toggle('active', (btn.getAttribute('data-filter') || '') === currentInstalledFilter);
});
}
if (typeof updateInstalledSortButtons === 'function') updateInstalledSortButtons();
if (typeof doApplyInstalledSort === 'function') doApplyInstalledSort();
document.querySelectorAll('.installed-category-btn').forEach(function(btn) {
btn.classList.toggle('active', (btn.getAttribute('data-category') || '') === currentInstalledCategory);
});
if (typeof filterInstalledPlugins === 'function') filterInstalledPlugins();
} catch (e) {
console.warn('applyInstalledStateFromHash', e);
} finally {
applyingInstalledHash = false;
}
}
// Get CSRF cookie helper function
function getCookie(name) {
@@ -1909,17 +2024,13 @@ function toggleView(view, updateHash = true) {
// Update URL hash only if explicitly requested (user clicked a button, not initial load)
if (updateHash) {
isSettingHash = true;
const hash = '#' + view;
// Use replaceState to update URL - this updates the hash without triggering hashchange event
const hash = buildPluginsHashString(view);
if (window.history && window.history.replaceState) {
// Get current pathname and preserve it, just update the hash
const newUrl = window.location.pathname + window.location.search + hash;
window.history.replaceState(null, null, newUrl);
} else {
// Fallback for older browsers - this will trigger hashchange but we have the flag
window.location.hash = hash;
window.location.hash = hash.replace(/^#/, '');
}
// Reset flag after a short delay
setTimeout(() => { isSettingHash = false; }, 100);
}
@@ -2434,6 +2545,7 @@ function setInstalledFilter(filter) {
try {
filterInstalledPlugins();
} catch (e) { console.warn('setInstalledFilter: filterInstalledPlugins', e); }
updateInstalledPluginsHashIfNeeded();
}
function filterInstalledPlugins() {
@@ -2446,10 +2558,11 @@ function filterInstalledPlugins() {
const noResultsTable = document.getElementById('installedPluginsNoResultsTable');
if (!gridView && !tableView) return;
var visibleCount = 0;
function matchesFilter(installed, enabled) {
function matchesFilter(installed, enabled, isPaid) {
if (filter === 'all') return true;
if (filter === 'installed') return installed === 'true';
if (filter === 'active') return installed === 'true' && enabled === 'true';
if (filter === 'premium') return isPaid === 'true';
return true;
}
var cat = (typeof currentInstalledCategory !== 'undefined' ? currentInstalledCategory : 'all');
@@ -2468,7 +2581,8 @@ function filterInstalledPlugins() {
var searchMatch = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; });
var installed = card.getAttribute('data-installed') || 'false';
var enabled = card.getAttribute('data-enabled') || 'false';
var filterMatch = matchesFilter(installed, enabled);
var isPaid = card.getAttribute('data-is-paid') || 'false';
var filterMatch = matchesFilter(installed, enabled, isPaid);
var categoryMatch = matchesCategory(card.getAttribute('data-plugin-type'));
var show = searchMatch && filterMatch && categoryMatch;
card.style.display = show ? '' : 'none';
@@ -2488,7 +2602,8 @@ function filterInstalledPlugins() {
var searchMatch = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; });
var installed = row.getAttribute('data-installed') || 'false';
var enabled = row.getAttribute('data-enabled') || 'false';
var filterMatch = matchesFilter(installed, enabled);
var isPaid = row.getAttribute('data-is-paid') || 'false';
var filterMatch = matchesFilter(installed, enabled, isPaid);
var categoryMatch = matchesCategory(row.getAttribute('data-plugin-type'));
var show = searchMatch && filterMatch && categoryMatch;
row.style.display = show ? '' : 'none';
@@ -2514,6 +2629,7 @@ function clearInstalledPluginSearch() {
filterInstalledPlugins();
input.focus();
}
updateInstalledPluginsHashIfNeeded();
}
function toggleInstalledSort(field) {
@@ -2526,6 +2642,7 @@ function toggleInstalledSort(field) {
}
updateInstalledSortButtons();
doApplyInstalledSort();
updateInstalledPluginsHashIfNeeded();
}
function updateInstalledSortButtons() {
@@ -2624,6 +2741,7 @@ function filterByCategoryInstalled(category, evt) {
});
}
try { filterInstalledPlugins(); } catch (e) { console.warn('filterByCategoryInstalled', e); }
updateInstalledPluginsHashIfNeeded();
}
function upgradePlugin(pluginName, currentVersion, newVersion) {
@@ -3490,6 +3608,7 @@ document.addEventListener('DOMContentLoaded', function() {
installedSearchClearBtn.style.display = this.value.trim() ? 'block' : 'none';
}
filterInstalledPlugins();
scheduleInstalledSearchHashUpdate();
});
installedSearchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
@@ -3499,22 +3618,21 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Check URL hash for view preference
const hash = window.location.hash.substring(1); // Remove #
// URL hash: #grid | #table | #store | #upgrades, optional ?show=&sort=&cat=&q= for grid/table
const hashRaw = window.location.hash.substring(1);
const parsedHash = parsePluginsHashFragment(hashRaw);
const validViews = ['grid', 'table', 'upgrades', 'store'];
// Check if view elements exist before calling toggleView
const gridView = document.getElementById('gridView');
const tableView = document.getElementById('tableView');
const storeView = document.getElementById('storeView');
const upgradesViewEl = document.getElementById('upgradesView');
// Only proceed if all view elements exist (plugins are installed)
if (gridView && tableView && storeView) {
let initialView = 'grid'; // Default
if (validViews.includes(hash)) {
initialView = hash;
} else {
let initialView = 'grid';
if (hashRaw.length > 0 && validViews.indexOf(parsedHash.view) !== -1) {
initialView = parsedHash.view;
} else if (hashRaw.length === 0) {
if (gridView.children.length > 0) {
initialView = 'grid';
} else {
@@ -3522,9 +3640,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
const hadHash = hash.length > 0;
const hadHash = hashRaw.length > 0;
try {
toggleView(initialView, hadHash);
if (initialView === 'grid' || initialView === 'table') {
currentInstalledFilter = parsedHash.show;
currentInstalledSort = parsedHash.sort;
currentInstalledCategory = parsedHash.cat;
var insPre = document.getElementById('installedPluginSearchInput');
var clrPre = document.getElementById('installedPluginSearchClear');
if (insPre) {
insPre.value = parsedHash.q;
if (clrPre) clrPre.style.display = parsedHash.q ? 'block' : 'none';
}
}
toggleView(initialView, false);
if (initialView === 'grid' || initialView === 'table') {
applyInstalledStateFromHash(parsedHash);
}
if (hadHash && (initialView === 'grid' || initialView === 'table' || initialView === 'upgrades' || initialView === 'store')) {
updateInstalledPluginsHashIfNeeded();
}
} catch (e) {
console.warn('plugins: toggleView on load failed', e);
if (storeView) storeView.style.display = 'block';
@@ -3549,17 +3684,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Handle hash changes (back/forward browser buttons)
window.addEventListener('hashchange', function() {
// Prevent infinite loops when we programmatically set the hash
if (isSettingHash) {
return;
}
const hash = window.location.hash.substring(1);
const validViews = ['grid', 'table', 'store'];
if (validViews.includes(hash)) {
// Don't update hash again since it's already set (user navigated via browser)
toggleView(hash, false);
const raw = window.location.hash.substring(1);
const parsed = parsePluginsHashFragment(raw);
var validViews = ['grid', 'table', 'upgrades', 'store'];
if (validViews.indexOf(parsed.view) !== -1) {
toggleView(parsed.view, false);
if (parsed.view === 'grid' || parsed.view === 'table') {
applyInstalledStateFromHash(parsed);
}
}
});
</script>

View File

@@ -371,6 +371,17 @@ class pluginInstaller:
py = pluginInstaller._manage_python_executable()
try:
os.chdir('/usr/local/CyberCP')
try:
from plogical import pluginMigrationSQL as _pmig
except ImportError:
_pmig = None
def _mig_log(msg):
pluginInstaller.stdOut(msg)
if _pmig is not None:
_pmig.ensure_login_system_migrations_applied(_mig_log)
mk = subprocess.call(
[py, manage_py, 'makemigrations', pluginName],
stdin=subprocess.DEVNULL,
@@ -379,14 +390,59 @@ class pluginInstaller:
pluginInstaller.stdOut(
'makemigrations %s exited %s (ok if no model changes)' % (pluginName, mk)
)
mig = subprocess.call(
proc = subprocess.run(
[py, manage_py, 'migrate', pluginName, '--noinput'],
cwd='/usr/local/CyberCP',
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=600,
)
if mig != 0:
if proc.returncode != 0:
err_tail = (proc.stderr or proc.stdout or '').strip()[:900]
pluginInstaller.stdOut(
'migrate %s exited %scheck CyberPanel logs and DB permissions' % (pluginName, mig)
'migrate %s exited %s%s'
% (pluginName, proc.returncode, err_tail or 'no stderr')
)
if _pmig is not None:
pluginInstaller.stdOut(
'Attempting SQL + migrate --fake fallback for %s..' % pluginName
)
if _pmig.apply_pending_migrations_via_sql_and_fake(pluginName, _mig_log):
pluginInstaller.stdOut('SQL migration fallback succeeded for %s; re-running migrate..' % pluginName)
proc2 = subprocess.run(
[py, manage_py, 'migrate', pluginName, '--noinput'],
cwd='/usr/local/CyberCP',
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=600,
)
if proc2.returncode != 0:
pluginInstaller.stdOut(
'migrate %s still failed after SQL fallback (rc=%s): %s'
% (
pluginName,
proc2.returncode,
(proc2.stderr or proc2.stdout or '')[:600],
)
)
else:
pluginInstaller.stdOut('migrate %s completed after SQL fallback.' % pluginName)
else:
pluginInstaller.stdOut(
'SQL migration fallback failed for %s — check DB user, mariadb client, and logs.'
% pluginName
)
try:
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log
_cp_log.writeToFile(
'pluginInstaller.installMigrations %s: migrate rc=%s %s'
% (pluginName, proc.returncode, err_tail[:400])
)
except Exception:
pass
finally:
try:
os.chdir(currentDir)
@@ -674,13 +730,53 @@ class pluginInstaller:
@staticmethod
def removeMigrations(pluginName):
currentDir = os.getcwd()
os.chdir('/usr/local/CyberCP')
py = pluginInstaller._manage_python_executable()
subprocess.call(
[py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'],
stdin=subprocess.DEVNULL,
)
os.chdir(currentDir)
try:
os.chdir('/usr/local/CyberCP')
py = pluginInstaller._manage_python_executable()
try:
from plogical import pluginMigrationSQL as _pmig
except ImportError:
_pmig = None
def _mig_log(msg):
pluginInstaller.stdOut(msg)
if _pmig is not None:
_pmig.ensure_login_system_migrations_applied(_mig_log)
proc = subprocess.run(
[py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'],
cwd='/usr/local/CyberCP',
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=600,
)
if proc.returncode != 0:
err_tail = (proc.stderr or proc.stdout or '').strip()[:900]
pluginInstaller.stdOut(
'migrate %s zero exited %s%s'
% (pluginName, proc.returncode, err_tail or 'no stderr')
)
if _pmig is not None:
pluginInstaller.stdOut(
'Attempting DROP TABLE + django_migrations cleanup for %s..' % pluginName
)
_pmig.drop_plugin_tables_and_migration_rows(pluginName, _mig_log)
try:
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log
_cp_log.writeToFile(
'pluginInstaller.removeMigrations %s: zero rc=%s %s'
% (pluginName, proc.returncode, err_tail[:400])
)
except Exception:
pass
finally:
try:
os.chdir(currentDir)
except OSError:
pass
@staticmethod
def removePlugin(pluginName):