mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-05-06 09:35:56 +02:00
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:
@@ -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
|
||||
|
||||
[](https://github.com/usmannasir/cyberpanel)
|
||||
[](https://cyberpanel.net/KnowledgeBase/)
|
||||
|
||||
104
loginSystem/migrations/0001_initial.py
Normal file
104
loginSystem/migrations/0001_initial.py
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# loginSystem migrations package (CyberPanel core)
|
||||
|
||||
209
plogical/pluginMigrationSQL.py
Normal file
209
plogical/pluginMigrationSQL.py
Normal 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])
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %s — check 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):
|
||||
|
||||
Reference in New Issue
Block a user