- Rewrote _apt helper as _run_cmd that never raises (returns rc/stdout/stderr) - Handle stderr warnings from apt-cache gracefully (common on Armbian) - Catch FileNotFoundError if binary missing despite PATH fix - Show actual backend error message in frontend instead of generic "Search failed" - Add /api/packages/debug endpoint for troubleshooting apt-cache issues - Add logging throughout for server-side diagnostics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
/* Atlus — Package Manager app */
|
|
(function () {
|
|
'use strict';
|
|
|
|
let container = null;
|
|
let searchInput = null;
|
|
let listEl = null;
|
|
let statusEl = null;
|
|
let searchTimeout = null;
|
|
let expandedPkg = null; // currently expanded package name
|
|
|
|
// ---- Search ----
|
|
|
|
async function searchPackages(query) {
|
|
if (!query || query.length < 2) {
|
|
listEl.innerHTML = '<div class="pkg-empty">Type at least 2 characters to search packages</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = '<div class="pkg-loading">Searching…</div>';
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/packages/search?q=${encodeURIComponent(query)}`);
|
|
if (res.status === 503) {
|
|
listEl.innerHTML = '<div class="pkg-empty">Package manager not available on this system</div>';
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
|
|
throw new Error(err.detail || `Search failed (${res.status})`);
|
|
}
|
|
const results = await res.json();
|
|
|
|
if (results.length === 0) {
|
|
listEl.innerHTML = '<div class="pkg-empty">No packages found</div>';
|
|
return;
|
|
}
|
|
|
|
renderResults(results);
|
|
updateStatus(`${results.length} result${results.length !== 1 ? 's' : ''}`);
|
|
} catch (e) {
|
|
listEl.innerHTML = `<div class="pkg-error">Error: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// ---- Render results ----
|
|
|
|
function renderResults(results) {
|
|
listEl.innerHTML = '';
|
|
|
|
results.forEach(pkg => {
|
|
const row = document.createElement('div');
|
|
row.className = 'pkg-row';
|
|
row.dataset.name = pkg.name;
|
|
|
|
row.innerHTML = `
|
|
<div class="pkg-row-header">
|
|
<div class="pkg-info">
|
|
<span class="pkg-name">${pkg.name}</span>
|
|
<span class="pkg-summary">${pkg.summary || ''}</span>
|
|
</div>
|
|
<span class="pkg-expand-icon">▸</span>
|
|
</div>
|
|
<div class="pkg-detail hidden"></div>
|
|
`;
|
|
|
|
const header = row.querySelector('.pkg-row-header');
|
|
const detail = row.querySelector('.pkg-detail');
|
|
|
|
header.addEventListener('click', () => {
|
|
if (expandedPkg === pkg.name) {
|
|
// Collapse
|
|
detail.classList.add('hidden');
|
|
row.classList.remove('expanded');
|
|
row.querySelector('.pkg-expand-icon').textContent = '▸';
|
|
expandedPkg = null;
|
|
} else {
|
|
// Collapse previous
|
|
if (expandedPkg) {
|
|
const prev = listEl.querySelector(`.pkg-row[data-name="${expandedPkg}"]`);
|
|
if (prev) {
|
|
prev.querySelector('.pkg-detail').classList.add('hidden');
|
|
prev.classList.remove('expanded');
|
|
prev.querySelector('.pkg-expand-icon').textContent = '▸';
|
|
}
|
|
}
|
|
// Expand this
|
|
expandedPkg = pkg.name;
|
|
row.classList.add('expanded');
|
|
row.querySelector('.pkg-expand-icon').textContent = '▾';
|
|
loadPackageDetail(pkg.name, detail);
|
|
}
|
|
});
|
|
|
|
listEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// ---- Package detail ----
|
|
|
|
async function loadPackageDetail(name, detailEl) {
|
|
detailEl.classList.remove('hidden');
|
|
detailEl.innerHTML = '<div class="pkg-loading">Loading…</div>';
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/packages/info/${encodeURIComponent(name)}`);
|
|
if (!res.ok) throw new Error('Failed to load package info');
|
|
const info = await res.json();
|
|
renderDetail(info, detailEl);
|
|
} catch (e) {
|
|
detailEl.innerHTML = `<div class="pkg-error">Error: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderDetail(info, detailEl) {
|
|
const installedBadge = info.installed
|
|
? `<span class="pkg-badge installed">Installed ${info.installed_version || ''}</span>`
|
|
: `<span class="pkg-badge not-installed">Not installed</span>`;
|
|
|
|
const sizeText = info.installed_size
|
|
? `${info.installed_size} KB installed`
|
|
: (info.size ? `${Atlus.formatBytes(parseInt(info.size))} download` : '');
|
|
|
|
detailEl.innerHTML = `
|
|
<div class="pkg-detail-header">
|
|
<div>
|
|
<span class="pkg-detail-version">v${info.version}</span>
|
|
${installedBadge}
|
|
</div>
|
|
<div class="pkg-detail-actions">
|
|
${info.installed
|
|
? `<button class="pkg-action-btn remove" data-name="${info.name}">Remove</button>`
|
|
: `<button class="pkg-action-btn install" data-name="${info.name}">Install</button>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="pkg-detail-desc">${info.description || 'No description available'}</div>
|
|
<div class="pkg-detail-meta">
|
|
${info.section ? `<span>Section: ${info.section}</span>` : ''}
|
|
${sizeText ? `<span>Size: ${sizeText}</span>` : ''}
|
|
${info.architecture ? `<span>Arch: ${info.architecture}</span>` : ''}
|
|
</div>
|
|
${info.depends ? `<div class="pkg-detail-deps"><span class="pkg-deps-label">Depends:</span> ${info.depends}</div>` : ''}
|
|
${info.homepage ? `<div class="pkg-detail-meta"><span>Homepage: ${info.homepage}</span></div>` : ''}
|
|
`;
|
|
|
|
// Action button handler
|
|
const actionBtn = detailEl.querySelector('.pkg-action-btn');
|
|
if (actionBtn) {
|
|
actionBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const pkgName = actionBtn.dataset.name;
|
|
if (actionBtn.classList.contains('install')) {
|
|
installPackage(pkgName, actionBtn, detailEl);
|
|
} else {
|
|
removePackage(pkgName, actionBtn, detailEl);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Install / Remove ----
|
|
|
|
async function installPackage(name, btn, detailEl) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Installing…';
|
|
updateStatus(`Installing ${name}…`);
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/packages/install', {
|
|
method: 'POST',
|
|
body: { name },
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Install failed');
|
|
}
|
|
updateStatus(`${name} installed successfully`);
|
|
// Reload detail to reflect new status
|
|
await loadPackageDetail(name, detailEl);
|
|
} catch (e) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Install';
|
|
updateStatus(`Error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function removePackage(name, btn, detailEl) {
|
|
if (!confirm(`Remove package "${name}"?`)) return;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Removing…';
|
|
updateStatus(`Removing ${name}…`);
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/packages/remove', {
|
|
method: 'POST',
|
|
body: { name },
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Remove failed');
|
|
}
|
|
updateStatus(`${name} removed successfully`);
|
|
// Reload detail to reflect new status
|
|
await loadPackageDetail(name, detailEl);
|
|
} catch (e) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Remove';
|
|
updateStatus(`Error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// ---- Refresh cache ----
|
|
|
|
async function refreshCache(btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Updating…';
|
|
updateStatus('Updating package cache…');
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/packages/update-cache', {
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) throw new Error('Cache update failed');
|
|
updateStatus('Package cache updated');
|
|
} catch (e) {
|
|
updateStatus(`Error: ${e.message}`);
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Refresh Cache';
|
|
}
|
|
|
|
// ---- Status ----
|
|
|
|
function updateStatus(text) {
|
|
if (statusEl) statusEl.textContent = text;
|
|
}
|
|
|
|
// ---- App registration ----
|
|
|
|
Atlus.registerApp('packages', {
|
|
title: 'Packages',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-packages');
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'pkg-toolbar';
|
|
|
|
searchInput = document.createElement('input');
|
|
searchInput.type = 'text';
|
|
searchInput.className = 'pkg-search';
|
|
searchInput.placeholder = 'Search packages…';
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
searchPackages(searchInput.value.trim());
|
|
}, 300);
|
|
});
|
|
toolbar.appendChild(searchInput);
|
|
|
|
const cacheBtn = document.createElement('button');
|
|
cacheBtn.className = 'pkg-cache-btn';
|
|
cacheBtn.textContent = 'Refresh Cache';
|
|
cacheBtn.addEventListener('click', () => refreshCache(cacheBtn));
|
|
toolbar.appendChild(cacheBtn);
|
|
|
|
container.appendChild(toolbar);
|
|
|
|
// Results list
|
|
listEl = document.createElement('div');
|
|
listEl.className = 'pkg-list';
|
|
listEl.innerHTML = '<div class="pkg-empty">Type at least 2 characters to search packages</div>';
|
|
container.appendChild(listEl);
|
|
|
|
// Status bar
|
|
statusEl = document.createElement('div');
|
|
statusEl.className = 'pkg-status';
|
|
statusEl.textContent = 'Ready';
|
|
container.appendChild(statusEl);
|
|
|
|
// Focus search
|
|
setTimeout(() => searchInput.focus(), 100);
|
|
},
|
|
|
|
destroy() {
|
|
clearTimeout(searchTimeout);
|
|
container = null;
|
|
searchInput = null;
|
|
listEl = null;
|
|
statusEl = null;
|
|
expandedPkg = null;
|
|
},
|
|
});
|
|
})();
|