Plugin Store & Installed Plugins: search bar, A-Å sort, sort toggle, Store A-Å label

- Installed plugins: search box in header (same row as Activate/Deactivate All)
- Grid/Table: default sort A-Å by name; sort bar with Name (toggle A-Å/Å-A), Type, Date (toggle newest/oldest)
- Apply sort on load so list shows A-Å when Name A-Å is selected
- Store view: letter filter label 'A-Å Filter' (not A-Z); add Æ, Ø, Å to letter buttons
- views.py: sort pluginList by name (case-insensitive) before template
- Add deploy-installed-plugins-search.sh for template deployment
This commit is contained in:
master3395
2026-02-02 20:39:56 +01:00
parent 1118f63b1a
commit d8ee83e30d
3 changed files with 379 additions and 15 deletions

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Deploy updated plugins.html (installed-plugins search bar) to CyberPanel
# Run on the server where CyberPanel is installed (e.g. 207.180.193.210)
# Usage: sudo bash deploy-installed-plugins-search.sh
# Or from repo root: sudo bash pluginHolder/deploy-installed-plugins-search.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
SRC="$SCRIPT_DIR/templates/pluginHolder/plugins.html"
DEST="/usr/local/CyberCP/pluginHolder/templates/pluginHolder/plugins.html"
if [ ! -f "$SRC" ]; then
echo "Error: Source not found: $SRC"
exit 1
fi
if [ ! -f "$DEST" ]; then
echo "Error: CyberPanel template not found: $DEST"
exit 1
fi
echo "Backing up current template..."
cp "$DEST" "${DEST}.bak.$(date +%Y%m%d)"
echo "Copying updated plugins.html..."
cp "$SRC" "$DEST"
echo "Restarting lscpd..."
systemctl restart lscpd
echo "Done. Hard-refresh the browser (Ctrl+Shift+R) and open /plugins/installed#grid"

View File

@@ -426,6 +426,52 @@
display: flex;
gap: 10px;
}
/* Sort/Filter bar for Grid and Table view */
.installed-sort-filter-bar {
flex-basis: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 12px 0;
border-top: 1px solid var(--border-primary, #e2e8f0);
}
.installed-sort-filter-bar .sort-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #64748b);
margin-right: 4px;
}
.installed-sort-filter-bar .sort-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.installed-sort-filter-bar .sort-btn {
padding: 6px 14px;
border: 1px solid var(--border-primary, #e8e9ff);
background: var(--bg-primary, white);
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary, #64748b);
display: inline-flex;
align-items: center;
gap: 6px;
}
.installed-sort-filter-bar .sort-btn:hover {
background: var(--bg-hover, #f0f1ff);
border-color: #5856d6;
color: #5856d6;
}
.installed-sort-filter-bar .sort-btn.active {
background: #5856d6;
color: white;
border-color: #5856d6;
}
.bulk-actions-header {
display: flex !important;
visibility: visible !important;
@@ -661,6 +707,16 @@
max-width: 480px;
}
/* Search bar in page header (same row as Activate/Deactivate All) */
.header-search-bar {
margin-bottom: 0;
min-width: 220px;
max-width: 320px;
margin-left: 8px;
padding-left: 16px;
border-left: 2px solid #e2e8f0;
}
.store-search-icon {
position: absolute;
left: 16px;
@@ -1123,6 +1179,13 @@
</span>
</div>
{% if plugins %}
<div id="installedPluginsSearchWrapper" class="header-search-bar store-search-bar">
<i class="fas fa-search store-search-icon"></i>
<input type="text" id="installedPluginSearchInput" class="store-search-input" placeholder="{% trans 'Search plugins by name or description...' %}" aria-label="{% trans 'Search installed plugins' %}">
<button type="button" class="store-search-clear" id="installedPluginSearchClear" onclick="clearInstalledPluginSearch()" style="display: none;" aria-label="{% trans 'Clear search' %}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bulk-actions-header" style="display: flex; gap: 10px; margin-left: 8px; padding-left: 16px; border-left: 2px solid #e2e8f0;">
<button type="button" class="btn-bulk btn-activate-all" onclick="activateAllPlugins()" title="{% trans 'Activate all installed plugins' %}">
<i class="fas fa-toggle-on"></i> {% trans "Activate All Plugins" %}
@@ -1161,12 +1224,29 @@
{% trans "Plugin Development Guide" %}
</a>
</div>
<!-- Sort bar for Grid and Table view (visible when grid or table is active) -->
<div id="installedSortFilterBar" class="installed-sort-filter-bar" style="display: none;"
data-trans-name-asc="{% trans 'Name A-Å' %}" data-trans-name-desc="{% trans 'Name Å-A' %}"
data-trans-date-newest="{% trans 'Date (newest)' %}" data-trans-date-oldest="{% trans 'Date (oldest)' %}">
<span class="sort-label">{% trans "Sort by:" %}</span>
<div class="sort-btns">
<button type="button" class="sort-btn active" data-sort-field="name" id="installedSortBtnName" onclick="toggleInstalledSort('name')" title="{% trans 'Click to toggle A-Å / Å-A' %}">
<i class="fas fa-sort-alpha-down"></i> <span class="sort-btn-label">{% trans "Name A-Å" %}</span>
</button>
<button type="button" class="sort-btn" data-sort-field="type" id="installedSortBtnType" onclick="toggleInstalledSort('type')" title="{% trans 'By category/type' %}">
<i class="fas fa-tag"></i> <span class="sort-btn-label">{% trans "Type" %}</span>
</button>
<button type="button" class="sort-btn" data-sort-field="date" id="installedSortBtnDate" onclick="toggleInstalledSort('date')" title="{% trans 'Click to toggle newest / oldest' %}">
<i class="fas fa-calendar-alt"></i> <span class="sort-btn-label">{% trans "Date (newest)" %}</span>
</button>
</div>
</div>
</div>
<!-- Grid View -->
<div id="gridView" class="plugins-grid">
{% for plugin in plugins %}
<div class="plugin-card">
<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' }}">
<div class="plugin-header">
<div class="plugin-icon">
{% if plugin.type|lower == "security" %}
@@ -1275,6 +1355,10 @@
</div>
</div>
{% endfor %}
<div id="installedPluginsNoResultsGrid" class="installed-no-results" style="display: none; grid-column: 1 / -1; text-align: center; padding: 24px; color: var(--text-secondary, #64748b);">
<i class="fas fa-search" style="font-size: 32px; margin-bottom: 8px; opacity: 0.6;"></i>
<p style="margin: 0;">{% trans "No plugins match your search." %}</p>
</div>
</div>
<!-- Table View -->
@@ -1295,7 +1379,7 @@
</thead>
<tbody>
{% for plugin in plugins %}
<tr>
<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' }}">
<td>
<strong>{{ plugin.name }}</strong>
{% if plugin.freshness_badge %}
@@ -1375,6 +1459,12 @@
</td>
</tr>
{% endfor %}
<tr id="installedPluginsNoResultsTable" style="display: none;">
<td colspan="8" style="text-align: center; padding: 24px; color: var(--text-secondary, #64748b);">
<i class="fas fa-search" style="font-size: 24px; margin-bottom: 8px; opacity: 0.6;"></i>
<p style="margin: 0;">{% trans "No plugins match your search." %}</p>
</td>
</tr>
</tbody>
</table>
</div>
@@ -1503,17 +1593,20 @@
</button>
</div>
<!-- Alphabetical Filter (collapsible, hidden by default) -->
<!-- Letter filter (A-Å, collapsible, hidden by default) -->
<div class="alphabet-filter-wrapper">
<button type="button" class="alphabet-toggle-btn" id="alphabetToggleBtn" onclick="toggleAlphabetFilter()" aria-expanded="false">
<i class="fas fa-sort-alpha-down alphabet-toggle-icon"></i>
<span>{% trans "A-Å Filter" %}</span>
<span>A-Å {% trans "Filter" %}</span>
<i class="fas fa-chevron-down alphabet-chevron"></i>
</button>
<div class="alphabet-filter" id="alphabetFilter" aria-hidden="true" style="display: none;">
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
<button class="alpha-btn" onclick="filterByLetter('{{ letter }}', event)">{{ letter }}</button>
{% endfor %}
<button class="alpha-btn" onclick="filterByLetter('Æ', event)">Æ</button>
<button class="alpha-btn" onclick="filterByLetter('Ø', event)">Ø</button>
<button class="alpha-btn" onclick="filterByLetter('Å', event)">Å</button>
<button class="alpha-btn active" onclick="filterByLetter('all', event)">{% trans "All" %}</button>
</div>
</div>
@@ -1555,6 +1648,7 @@ 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
// Get CSRF cookie helper function
function getCookie(name) {
@@ -1597,17 +1691,31 @@ function toggleView(view, updateHash = true) {
setTimeout(() => { isSettingHash = false; }, 100);
}
const installedSearchWrapper = document.getElementById('installedPluginsSearchWrapper');
const installedSortFilterBar = document.getElementById('installedSortFilterBar');
if (view === 'grid') {
gridView.style.display = 'grid';
tableView.style.display = 'none';
storeView.style.display = 'none';
viewBtns[0].classList.add('active');
if (installedSearchWrapper) installedSearchWrapper.style.display = 'block';
if (installedSortFilterBar) installedSortFilterBar.style.display = 'flex';
if (typeof updateInstalledSortButtons === 'function') updateInstalledSortButtons();
if (typeof doApplyInstalledSort === 'function') doApplyInstalledSort();
filterInstalledPlugins();
} else if (view === 'table') {
gridView.style.display = 'none';
tableView.style.display = 'block';
storeView.style.display = 'none';
viewBtns[1].classList.add('active');
if (installedSearchWrapper) installedSearchWrapper.style.display = 'block';
if (installedSortFilterBar) installedSortFilterBar.style.display = 'flex';
if (typeof updateInstalledSortButtons === 'function') updateInstalledSortButtons();
if (typeof doApplyInstalledSort === 'function') doApplyInstalledSort();
filterInstalledPlugins();
} else if (view === 'store') {
if (installedSearchWrapper) installedSearchWrapper.style.display = 'none';
if (installedSortFilterBar) installedSortFilterBar.style.display = 'none';
gridView.style.display = 'none';
tableView.style.display = 'none';
storeView.style.display = 'block';
@@ -1911,6 +2019,137 @@ function clearPluginSearch() {
}
}
function filterInstalledPlugins() {
const query = (document.getElementById('installedPluginSearchInput') && document.getElementById('installedPluginSearchInput').value) || '';
const terms = query.trim().toLowerCase().split(/\s+/).filter(function(t) { return t.length > 0; });
const gridView = document.getElementById('gridView');
const tableView = document.getElementById('tableView');
const noResultsGrid = document.getElementById('installedPluginsNoResultsGrid');
const noResultsTable = document.getElementById('installedPluginsNoResultsTable');
var visibleCount = 0;
if (gridView) {
var cards = gridView.querySelectorAll('.plugin-card');
cards.forEach(function(card) {
var name = (card.getAttribute('data-plugin-name') || '').toLowerCase();
var desc = (card.getAttribute('data-plugin-desc') || '').toLowerCase();
var type = (card.getAttribute('data-plugin-type') || '').toLowerCase();
var combined = name + ' ' + desc + ' ' + type;
var show = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; });
card.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
}
if (tableView) {
var tbody = tableView.querySelector('.plugins-table-wrapper tbody');
if (tbody) {
var rows = tbody.querySelectorAll('tr');
rows.forEach(function(row) {
if (row.id === 'installedPluginsNoResultsTable') return;
var name = (row.getAttribute('data-plugin-name') || '').toLowerCase();
var desc = (row.getAttribute('data-plugin-desc') || '').toLowerCase();
var type = (row.getAttribute('data-plugin-type') || '').toLowerCase();
var combined = name + ' ' + desc + ' ' + type;
var show = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; });
row.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
}
}
if (noResultsGrid) {
noResultsGrid.style.display = (terms.length > 0 && visibleCount === 0) ? 'block' : 'none';
}
if (noResultsTable) {
noResultsTable.style.display = (terms.length > 0 && visibleCount === 0) ? 'table-row' : 'none';
}
}
function clearInstalledPluginSearch() {
var input = document.getElementById('installedPluginSearchInput');
var clearBtn = document.getElementById('installedPluginSearchClear');
if (input) {
input.value = '';
if (clearBtn) clearBtn.style.display = 'none';
filterInstalledPlugins();
input.focus();
}
}
function toggleInstalledSort(field) {
if (field === 'name') {
currentInstalledSort = currentInstalledSort === 'name-asc' ? 'name-desc' : 'name-asc';
} else if (field === 'date') {
currentInstalledSort = currentInstalledSort === 'date-desc' ? 'date-asc' : 'date-desc';
} else {
currentInstalledSort = 'type';
}
updateInstalledSortButtons();
doApplyInstalledSort();
}
function updateInstalledSortButtons() {
var bar = document.getElementById('installedSortFilterBar');
var transNameAsc = bar && bar.getAttribute('data-trans-name-asc') || 'Name A-Å';
var transNameDesc = bar && bar.getAttribute('data-trans-name-desc') || 'Name Å-A';
var transDateNewest = bar && bar.getAttribute('data-trans-date-newest') || 'Date (newest)';
var transDateOldest = bar && bar.getAttribute('data-trans-date-oldest') || 'Date (oldest)';
var nameBtn = document.getElementById('installedSortBtnName');
var typeBtn = document.getElementById('installedSortBtnType');
var dateBtn = document.getElementById('installedSortBtnDate');
if (nameBtn) {
nameBtn.classList.toggle('active', currentInstalledSort === 'name-asc' || currentInstalledSort === 'name-desc');
var nameLabel = nameBtn.querySelector('.sort-btn-label');
var nameIcon = nameBtn.querySelector('i');
if (nameLabel) nameLabel.textContent = currentInstalledSort === 'name-desc' ? transNameDesc : transNameAsc;
if (nameIcon) nameIcon.className = currentInstalledSort === 'name-desc' ? 'fas fa-sort-alpha-down-alt' : 'fas fa-sort-alpha-down';
}
if (typeBtn) typeBtn.classList.toggle('active', currentInstalledSort === 'type');
if (dateBtn) {
dateBtn.classList.toggle('active', currentInstalledSort === 'date-asc' || currentInstalledSort === 'date-desc');
var dateLabel = dateBtn.querySelector('.sort-btn-label');
var dateIcon = dateBtn.querySelector('i');
if (dateLabel) dateLabel.textContent = currentInstalledSort === 'date-asc' ? transDateOldest : transDateNewest;
if (dateIcon) dateIcon.className = currentInstalledSort === 'date-asc' ? 'fas fa-calendar' : 'fas fa-calendar-alt';
}
}
function doApplyInstalledSort() {
var sortKey = currentInstalledSort;
var gridView = document.getElementById('gridView');
var tableView = document.getElementById('tableView');
var noResultsGrid = document.getElementById('installedPluginsNoResultsGrid');
var noResultsTable = document.getElementById('installedPluginsNoResultsTable');
function compareCards(a, b) {
var nameA = (a.getAttribute('data-plugin-name') || '').toLowerCase();
var nameB = (b.getAttribute('data-plugin-name') || '').toLowerCase();
var typeA = (a.getAttribute('data-plugin-type') || '').toLowerCase();
var typeB = (b.getAttribute('data-plugin-type') || '').toLowerCase();
var dateA = a.getAttribute('data-modify-date') || '0000-00-00 00:00:00';
var dateB = b.getAttribute('data-modify-date') || '0000-00-00 00:00:00';
if (sortKey === 'name-asc') return nameA.localeCompare(nameB);
if (sortKey === 'name-desc') return nameB.localeCompare(nameA);
if (sortKey === 'type') { var c = typeA.localeCompare(typeB); return c !== 0 ? c : nameA.localeCompare(nameB); }
if (sortKey === 'date-desc') return dateB.localeCompare(dateA);
if (sortKey === 'date-asc') return dateA.localeCompare(dateB);
return 0;
}
if (gridView) {
var cards = Array.prototype.slice.call(gridView.querySelectorAll('.plugin-card'));
cards.sort(compareCards);
cards.forEach(function(card) { gridView.appendChild(card); });
if (noResultsGrid) gridView.appendChild(noResultsGrid);
}
if (tableView) {
var tbody = tableView.querySelector('.plugins-table-wrapper tbody');
if (tbody) {
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
var dataRows = rows.filter(function(r) { return r.id !== 'installedPluginsNoResultsTable'; });
dataRows.sort(compareCards);
dataRows.forEach(function(row) { tbody.appendChild(row); });
if (noResultsTable) tbody.appendChild(noResultsTable);
}
}
}
function toggleAlphabetFilter() {
const filter = document.getElementById('alphabetFilter');
const toggleBtn = document.getElementById('alphabetToggleBtn');
@@ -2680,6 +2919,24 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Installed plugins search (Grid View and Table View)
const installedSearchInput = document.getElementById('installedPluginSearchInput');
const installedSearchClearBtn = document.getElementById('installedPluginSearchClear');
if (installedSearchInput) {
installedSearchInput.addEventListener('input', function() {
if (installedSearchClearBtn) {
installedSearchClearBtn.style.display = this.value.trim() ? 'block' : 'none';
}
filterInstalledPlugins();
});
installedSearchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
clearInstalledPluginSearch();
e.preventDefault();
}
});
}
// Check URL hash for view preference
const hash = window.location.hash.substring(1); // Remove #
const validViews = ['grid', 'table', 'store'];

View File

@@ -35,6 +35,18 @@ GITHUB_COMMITS_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins
# Plugin backup configuration
PLUGIN_BACKUP_DIR = '/home/cyberpanel/plugin_backups'
# Plugin source paths (checked in order; first match wins for install)
PLUGIN_SOURCE_PATHS = ['/home/cyberpanel/plugins', '/home/cyberpanel-plugins']
def _get_plugin_source_path(plugin_name):
"""Return the full path to a plugin's source directory, or None if not found."""
for base in PLUGIN_SOURCE_PATHS:
path = os.path.join(base, plugin_name)
meta_path = os.path.join(path, 'meta.xml')
if os.path.isdir(path) and os.path.exists(meta_path):
return path
return None
def _get_plugin_state_file(plugin_name):
"""Get the path to the plugin state file"""
if not os.path.exists(PLUGIN_STATE_DIR):
@@ -100,15 +112,23 @@ def help_page(request):
def installed(request):
mailUtilities.checkHome()
pluginPath = '/home/cyberpanel/plugins'
installedPath = '/usr/local/CyberCP'
pluginList = []
errorPlugins = []
processed_plugins = set() # Track which plugins we've already processed
# First, process plugins from source directory
if os.path.exists(pluginPath):
# First, process plugins from source directories (multiple paths: /home/cyberpanel/plugins, /home/cyberpanel-plugins)
for pluginPath in PLUGIN_SOURCE_PATHS:
if not os.path.exists(pluginPath):
continue
try:
dirs_in_path = [p for p in os.listdir(pluginPath) if os.path.isdir(os.path.join(pluginPath, p))]
logging.writeToFile(f"Plugin source path {pluginPath}: directories {sorted(dirs_in_path)}")
except Exception as e:
logging.writeToFile(f"Plugin source path {pluginPath}: listdir error {e}")
for plugin in os.listdir(pluginPath):
if plugin in processed_plugins:
continue
# Skip files (like .zip files) - only process directories
pluginDir = os.path.join(pluginPath, plugin)
if not os.path.isdir(pluginDir):
@@ -130,7 +150,8 @@ def installed(request):
# Add error handling to prevent 500 errors
try:
if metaXmlPath is None:
# No meta.xml found in either location - skip silently
# No meta.xml found in either location - skip (log for diagnostics)
logging.writeToFile(f"Plugin {plugin}: skipped (no meta.xml in source or installed)")
continue
pluginMetaData = ElementTree.parse(metaXmlPath)
@@ -383,6 +404,60 @@ def installed(request):
logging.writeToFile(f"Installed plugin {plugin}: Error loading - {str(e)}")
continue
# Ensure redisManager and memcacheManager load when present (fallback if missed by listdir)
for plugin_name in ('redisManager', 'memcacheManager'):
if plugin_name in processed_plugins:
continue
source_path = _get_plugin_source_path(plugin_name)
installed_meta = os.path.join(installedPath, plugin_name, 'meta.xml')
meta_xml_path = installed_meta if os.path.exists(installed_meta) else (os.path.join(source_path, 'meta.xml') if source_path else None)
if not meta_xml_path or not os.path.exists(meta_xml_path):
continue
try:
root = ElementTree.parse(meta_xml_path).getroot()
name_elem = root.find('name')
type_elem = root.find('type')
desc_elem = root.find('description')
version_elem = root.find('version')
if name_elem is None or type_elem is None or desc_elem is None or version_elem is None:
continue
type_text = (type_elem.text or '').strip()
if not type_text or name_elem.text is None or desc_elem.text is None or version_elem.text is None:
continue
if type_text.lower() not in ('utility', 'security', 'backup', 'performance', 'monitoring', 'integration', 'email', 'development', 'analytics'):
continue
complete_path = os.path.join(installedPath, plugin_name, 'meta.xml')
data = {
'name': name_elem.text,
'type': type_text,
'desc': desc_elem.text,
'version': version_elem.text,
'plugin_dir': plugin_name,
'installed': os.path.exists(complete_path),
'enabled': _is_plugin_enabled(plugin_name) if os.path.exists(complete_path) else False,
'is_paid': False,
'patreon_tier': None,
'patreon_url': None,
'manage_url': f'/plugins/{plugin_name}/',
'author': root.find('author').text if root.find('author') is not None and root.find('author').text else 'Unknown',
}
try:
modify_time = os.path.getmtime(meta_xml_path)
data['modify_date'] = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
data['modify_date'] = 'N/A'
data['freshness_badge'] = _get_freshness_badge(data['modify_date'])
paid_elem = root.find('paid')
if paid_elem is not None and paid_elem.text and paid_elem.text.lower() == 'true':
data['is_paid'] = True
data['patreon_tier'] = 'CyberPanel Paid Plugin'
data['patreon_url'] = root.find('patreon_url').text if root.find('patreon_url') is not None else 'https://www.patreon.com/membership/27789984'
pluginList.append(data)
processed_plugins.add(plugin_name)
logging.writeToFile(f"Plugin {plugin_name}: added via fallback (source or installed)")
except Exception as e:
logging.writeToFile(f"Plugin {plugin_name} fallback load error: {str(e)}")
# Calculate installed and active counts
# Double-check by also counting plugins that actually exist in /usr/local/CyberCP/
installed_plugins_in_filesystem = set()
@@ -415,6 +490,9 @@ def installed(request):
# Get cache expiry timestamp for display (will be converted to local time in browser)
cache_expiry_timestamp, _ = _get_cache_expiry_time()
# Sort plugins A-Å by name (case-insensitive) for Grid and Table view
pluginList.sort(key=lambda p: (p.get('name') or '').lower())
proc = httpProc(request, 'pluginHolder/plugins.html',
{'plugins': pluginList, 'error_plugins': errorPlugins,
'installed_count': installed_count, 'active_count': active_count,
@@ -426,12 +504,12 @@ def installed(request):
def install_plugin(request, plugin_name):
"""Install a plugin"""
try:
# Check if plugin source exists
pluginSource = '/home/cyberpanel/plugins/' + plugin_name
if not os.path.exists(pluginSource):
# Check if plugin source exists (in any configured source path)
pluginSource = _get_plugin_source_path(plugin_name)
if not pluginSource:
return JsonResponse({
'success': False,
'error': f'Plugin source not found: {plugin_name}'
'error': f'Plugin source not found: {plugin_name} (checked: {", ".join(PLUGIN_SOURCE_PATHS)})'
}, status=404)
# Check if already installed
@@ -1546,9 +1624,9 @@ def install_from_store(request, plugin_name):
# Fallback to local source if GitHub download failed
if use_local_fallback:
pluginSource = '/home/cyberpanel/plugins/' + plugin_name
if not os.path.exists(pluginSource):
raise Exception(f'Plugin {plugin_name} not found in GitHub repository and local source not found at {pluginSource}')
pluginSource = _get_plugin_source_path(plugin_name)
if not pluginSource:
raise Exception(f'Plugin {plugin_name} not found in GitHub repository and local source not found (checked: {", ".join(PLUGIN_SOURCE_PATHS)})')
logging.writeToFile(f"Using local source for {plugin_name} from {pluginSource}")