Compare commits
2 Commits
193206cbf5
...
0d7cab8c97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7cab8c97 | ||
|
|
b5107e30cc |
100
containers/libreportal/frontend/css/admin.css
Normal file
100
containers/libreportal/frontend/css/admin.css
Normal file
@ -0,0 +1,100 @@
|
||||
/* Admin area — Overview board + shared admin-page chrome. Visually aligned
|
||||
with the backup dashboard (tile/card style) and the config page header. */
|
||||
|
||||
.admin-page {
|
||||
padding: 4px 2px 40px;
|
||||
}
|
||||
|
||||
.admin-breadcrumb {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Overview cards */
|
||||
.admin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.admin-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-status-dot.ok { background: #36d399; }
|
||||
.admin-status-dot.warn { background: #fbbd23; }
|
||||
.admin-status-dot.none { background: rgba(var(--text-rgb), 0.25); }
|
||||
|
||||
.admin-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-card-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(var(--text-rgb), 0.6);
|
||||
}
|
||||
.admin-card-line strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-card-actions {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(var(--text-rgb), 0.08);
|
||||
}
|
||||
|
||||
.admin-card-ok {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
}
|
||||
|
||||
/* Small entry point on the user Dashboard that links into the Admin area. */
|
||||
.dashboard-admin-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-admin-link:hover { text-decoration: underline; }
|
||||
@ -46,6 +46,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/config?=overview" class="dashboard-admin-link">
|
||||
Admin overview
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</a>
|
||||
|
||||
<!-- Installed Apps Icons -->
|
||||
<div id="frontpage-apps-section" class="frontpage-apps-section" style="display:none;">
|
||||
<div id="frontpage-apps-container" class="frontpage-apps-grid"></div>
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
<link rel="stylesheet" href="/css/port-manager.css">
|
||||
<link rel="stylesheet" href="/css/backup.css">
|
||||
<link rel="stylesheet" href="/css/ssh.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/services.css">
|
||||
<link rel="stylesheet" href="/css/modal.css">
|
||||
<link rel="stylesheet" href="/css/tools.css">
|
||||
@ -99,6 +100,7 @@
|
||||
<script src="/js/components/backup/backup-page.js"></script>
|
||||
<script src="/js/components/backup/backup-app-card.js"></script>
|
||||
<script src="/js/components/ssh/ssh-page.js"></script>
|
||||
<script src="/js/components/admin/admin-overview.js"></script>
|
||||
<script src="/js/spa.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
// Admin Overview — the Admin area's landing page. An ops/health board (distinct
|
||||
// from the user Dashboard, which is app-centric): it summarises updates,
|
||||
// backups, SSH/security and system health from data we already generate, and
|
||||
// each card links to where you act on it. Renders into #config-section.
|
||||
class AdminOverview {
|
||||
constructor(rootId = 'config-section') {
|
||||
this.rootId = rootId;
|
||||
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
|
||||
this._bound = false;
|
||||
}
|
||||
|
||||
root() { return document.getElementById(this.rootId); }
|
||||
|
||||
async init() {
|
||||
const r = this.root();
|
||||
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading…</div></div>';
|
||||
this.bindEvents();
|
||||
const [upd, backup, ssh, disk, mem, info] = await Promise.all([
|
||||
this.fetchJson('/data/system/update_status.json'),
|
||||
this.fetchJson('/data/backup/generated/dashboard.json'),
|
||||
this.fetchJson('/data/ssh/access.json'),
|
||||
this.fetchJson('/data/system/disk_usage.json'),
|
||||
this.fetchJson('/data/system/memory_usage.json'),
|
||||
this.fetchJson('/data/system/system_info.json')
|
||||
]);
|
||||
this.d = { upd, backup, ssh, disk, mem, info };
|
||||
this.render();
|
||||
}
|
||||
|
||||
async fetchJson(url) {
|
||||
try { const r = await fetch(`${url}?t=${Date.now()}`); if (!r.ok) return null; return await r.json(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
escape(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
notify(msg, type) {
|
||||
if (window.notificationSystem) window.notificationSystem.show(msg, type || 'info');
|
||||
else console.log(`[admin ${type || 'info'}] ${msg}`);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this._bound) return;
|
||||
this._bound = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
const go = e.target.closest('[data-admin-go]');
|
||||
if (go) { this.go(go.dataset.adminGo); return; }
|
||||
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
|
||||
});
|
||||
}
|
||||
|
||||
go(where) {
|
||||
if (where === 'backup') {
|
||||
window.librePortalSPA?.navigate('/backup', true);
|
||||
} else if (where === 'ssh' || where === 'security') {
|
||||
const target = where === 'ssh' ? 'ssh-access' : 'security';
|
||||
window.history.pushState({}, '', `/config?=${target}`);
|
||||
window.configCategory = target;
|
||||
window.configManager?.renderConfig?.(target);
|
||||
}
|
||||
}
|
||||
|
||||
async runUpdate() {
|
||||
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
|
||||
if (!confirm('Update LibrePortal now? The WebUI may restart briefly.')) return;
|
||||
try { await this.taskManager.createTask('libreportal update apply', 'update', null); }
|
||||
catch (e) { this.notify(`Failed to start update: ${e.message || e}`, 'error'); }
|
||||
}
|
||||
|
||||
/* A status card: kind sets the dot colour (ok/warn/none). actionsHtml is
|
||||
the footer (Manage link / button). */
|
||||
card(title, kind, lines, actionsHtml) {
|
||||
return `
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">
|
||||
<span class="admin-card-title">${this.escape(title)}</span>
|
||||
<span class="admin-status-dot ${kind}"></span>
|
||||
</div>
|
||||
<div class="admin-card-body">${lines}</div>
|
||||
<div class="admin-card-actions">${actionsHtml || ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
line(label, value) {
|
||||
return `<div class="admin-card-line"><span>${this.escape(label)}</span><strong>${this.escape(value)}</strong></div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const d = this.d || {};
|
||||
|
||||
// Updates
|
||||
const upd = d.upd || {};
|
||||
const updAvail = upd.update_available === true;
|
||||
const updCard = this.card(
|
||||
'Updates',
|
||||
updAvail ? 'warn' : 'ok',
|
||||
updAvail
|
||||
? this.line('Status', 'Update available') + this.line('Current → latest', `${upd.current_version || '?'} → ${upd.latest_version || '?'}`)
|
||||
: this.line('Status', 'Up to date') + this.line('Version', upd.current_version || '—'),
|
||||
updAvail && upd.can_update
|
||||
? `<button type="button" class="backup-primary-btn" data-admin-update>Update now</button>`
|
||||
: `<span class="admin-card-ok">Nothing to do</span>`
|
||||
);
|
||||
|
||||
// Backups
|
||||
const b = d.backup || {};
|
||||
const apps = Array.isArray(b.apps) ? b.apps : [];
|
||||
const locs = Array.isArray(b.locations) ? b.locations : [];
|
||||
const protectedApps = apps.filter(a => a.latest_snapshot).length;
|
||||
const totalSize = locs.reduce((a, r) => a + (parseInt(r.total_size_bytes) || 0), 0);
|
||||
const noBackups = apps.length && protectedApps === 0;
|
||||
const backupCard = this.card(
|
||||
'Backups',
|
||||
!locs.length ? 'none' : (noBackups ? 'warn' : 'ok'),
|
||||
this.line('Apps protected', `${protectedApps} / ${apps.length}`)
|
||||
+ this.line('Locations', String(locs.length))
|
||||
+ this.line('Stored', this.bytes(totalSize)),
|
||||
`<button type="button" class="backup-secondary-btn" data-admin-go="backup">Manage backups →</button>`
|
||||
);
|
||||
|
||||
// SSH & Security
|
||||
const s = d.ssh || {};
|
||||
const keyCount = Array.isArray(s.keys) ? s.keys.length : 0;
|
||||
const pwOn = s.password_auth === true;
|
||||
// Warn when password login is on AND no keys (the weakest posture).
|
||||
const sshKind = (pwOn && keyCount === 0) ? 'warn' : 'ok';
|
||||
const sshCard = this.card(
|
||||
'SSH & Security',
|
||||
sshKind,
|
||||
this.line('Password login', pwOn ? 'On' : 'Key-only')
|
||||
+ this.line('Authorized keys', String(keyCount))
|
||||
+ this.line('Login user', s.user || '—'),
|
||||
`<button type="button" class="backup-secondary-btn" data-admin-go="ssh">Manage SSH access →</button>`
|
||||
);
|
||||
|
||||
// System health
|
||||
const disk = d.disk?.root || {};
|
||||
const mem = d.mem || {};
|
||||
const info = d.info || {};
|
||||
const diskPct = parseInt(disk.percent) || 0;
|
||||
const sysKind = diskPct >= 90 ? 'warn' : 'ok';
|
||||
const sysCard = this.card(
|
||||
'System',
|
||||
sysKind,
|
||||
this.line('Disk', disk.text || `${diskPct}%`)
|
||||
+ this.line('Memory', mem.text || '—')
|
||||
+ this.line('Uptime', (info.uptime || '—').replace(/^up /, ''))
|
||||
+ this.line('OS', info.os || '—'),
|
||||
`<span class="admin-card-ok">${diskPct >= 90 ? '⚠ Disk almost full' : 'Healthy'}</span>`
|
||||
);
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="admin-page">
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">Admin</div>
|
||||
<h1>Overview</h1>
|
||||
<p>System health and admin status at a glance. Manage anything from the cards below.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card-grid">
|
||||
${updCard}
|
||||
${backupCard}
|
||||
${sshCard}
|
||||
${sysCard}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
bytes(n) {
|
||||
n = parseInt(n) || 0;
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.AdminOverview = AdminOverview;
|
||||
@ -25,6 +25,18 @@ if (typeof window.ConfigManager === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Overview is the Admin landing — an ops/health board, not a config form.
|
||||
if (category === 'overview') {
|
||||
try { this.sidebar.populateSidebar(); } catch (e) {}
|
||||
if (typeof AdminOverview !== 'undefined') {
|
||||
window.adminOverview = new AdminOverview('config-section');
|
||||
await window.adminOverview.init();
|
||||
} else {
|
||||
configSection.innerHTML = '<div class="error">Admin overview failed to load.</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SSH Access is an admin tool page that lives in this sidebar rather than
|
||||
// a config category — render its own controller into the main pane.
|
||||
if (category === 'ssh-access') {
|
||||
@ -155,6 +167,7 @@ if (typeof window.ConfigManager === 'undefined') {
|
||||
'<div class="page-header config-page-header">' +
|
||||
'<img class="page-header-icon" src="/icons/config/' + catIcon + '.svg" alt="" onerror="this.style.display=\'none\'">' +
|
||||
'<div class="page-header-title">' +
|
||||
'<div class="admin-breadcrumb">Admin</div>' +
|
||||
'<h1>' + catTitle + '</h1>' +
|
||||
(catDesc ? '<p>' + catDesc + '</p>' : '') +
|
||||
'</div>' +
|
||||
|
||||
@ -19,7 +19,23 @@ class ConfigSidebar {
|
||||
}
|
||||
|
||||
this.categoriesList.innerHTML = '';
|
||||
|
||||
|
||||
// Overview — the Admin landing (an ops/health board, not a config form).
|
||||
const overviewItem = document.createElement('div');
|
||||
overviewItem.className = 'category';
|
||||
overviewItem.setAttribute('data-category', 'overview');
|
||||
overviewItem.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px;vertical-align:middle"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg> Overview';
|
||||
overviewItem.addEventListener('click', function () {
|
||||
window.history.pushState({}, '', '/config?=overview');
|
||||
document.querySelectorAll('.category').forEach(function (item) { item.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
window.configCategory = 'overview';
|
||||
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
|
||||
window.configManager.renderConfig('overview');
|
||||
}
|
||||
});
|
||||
this.categoriesList.appendChild(overviewItem);
|
||||
|
||||
// Convert categories object to array and sort by ORDER
|
||||
const categoriesArray = Object.entries(window.configData.categories).map(([key, value]) => ({
|
||||
id: key,
|
||||
@ -97,7 +113,7 @@ class ConfigSidebar {
|
||||
self.categoriesList.appendChild(sshItem);
|
||||
|
||||
// Set initial active category
|
||||
this.setActiveCategory(window.configCategory || 'general');
|
||||
this.setActiveCategory(window.configCategory || 'overview');
|
||||
|
||||
//console.log('ConfigSidebar: Sidebar populated with ' + categoriesArray.length + ' categories');
|
||||
}
|
||||
|
||||
@ -77,9 +77,12 @@ class SshPage {
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="ssh-page">
|
||||
<div class="ssh-page-header">
|
||||
<h1>SSH Access</h1>
|
||||
<p class="ssh-page-sub">Control who can SSH into this server. Grant access by adding a public key, and optionally require key-only login.</p>
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">Admin</div>
|
||||
<h1>SSH Access</h1>
|
||||
<p>Control who can SSH into this server. Grant access by adding a public key, and optionally require key-only login.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-ssh-key-card">
|
||||
|
||||
@ -370,13 +370,13 @@ class LibrePortalSPAClean {
|
||||
const path = window.location.pathname + window.location.search;
|
||||
if (path.includes('?=')) {
|
||||
const [basePath, query] = path.split('?=');
|
||||
window.configCategory = query || 'general';
|
||||
window.configCategory = query || 'overview';
|
||||
} else if (path.includes('?')) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
const searchParams = url.searchParams;
|
||||
window.configCategory = searchParams.get('config') || 'general';
|
||||
window.configCategory = searchParams.get('config') || 'overview';
|
||||
} else {
|
||||
window.configCategory = 'general';
|
||||
window.configCategory = 'overview';
|
||||
}
|
||||
|
||||
try {
|
||||
@ -387,7 +387,7 @@ class LibrePortalSPAClean {
|
||||
if (window.configManager) {
|
||||
// Render the actual configuration
|
||||
if (typeof window.configManager.renderConfig === 'function') {
|
||||
await window.configManager.renderConfig(window.configCategory || 'general');
|
||||
await window.configManager.renderConfig(window.configCategory || 'overview');
|
||||
}
|
||||
//console.log('✅ Config loaded');
|
||||
} else {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user