atlus/frontend/js/apps/packages.js
roberts c3e127421b Fix package manager: better error handling, debug endpoint, robust PATH
- 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>
2026-03-14 19:33:04 -05:00

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;
},
});
})();