diff --git a/platform/app.py b/platform/app.py index 6bf6d08..7034b7a 100644 --- a/platform/app.py +++ b/platform/app.py @@ -91,19 +91,23 @@ def dashboard(): vm_backups = get_vm_backups() if RUNNING_ON_MAIN_SERVER: - containers = get_containers() - running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) + root_ctrs = get_all_root_containers() + user_ctrs = get_rootless_user_containers_remote() + all_ctrs = root_ctrs + user_ctrs + running_count = sum(1 for c in all_ctrs if c.get('running')) system = get_system_info() users = get_all_users() + container_count = len(all_ctrs) else: - containers = [] running_count = 0 system = {} users = [] + container_count = 0 return render_template('pages/dashboard.html', - containers=containers, + container_count=container_count, running_count=running_count, + site_count=len(SITES), backups=backups, vm_backups=vm_backups, main_server=MAIN_SERVER_IP, @@ -295,15 +299,18 @@ def api_dashboard(): user_ctrs = get_rootless_user_containers_remote() all_ctrs = root_ctrs + user_ctrs users = get_all_users() - running = sum(1 for c in all_ctrs if 'Up' in c.get('status', '')) + running = sum(1 for c in all_ctrs if c.get('running')) + stopped = len(all_ctrs) - running return jsonify({ - 'system': system, - 'containers': all_ctrs, - 'running_count': running, - 'user_count': len(users), - 'local_backups': len(get_local_backups()), - 'vm_backups': len(get_vm_backups()), + 'system': system, + 'container_count': len(all_ctrs), + 'running_count': running, + 'stopped_count': stopped, + 'sites_count': len(SITES), + 'user_count': len(users), + 'local_backups': len(get_local_backups()), + 'vm_backups': len(get_vm_backups()), }) diff --git a/platform/modules/backups.py b/platform/modules/backups.py index 3e2d3bb..4293b46 100644 --- a/platform/modules/backups.py +++ b/platform/modules/backups.py @@ -446,6 +446,18 @@ def get_backup_script_path(): # CONTAINERS # ──────────────────────────────────────────────────────────────── +def _parse_uptime(status): + """Human uptime snippet from docker ps Status field.""" + if not status: + return '—' + if status.startswith('Up'): + m = re.match(r'Up\s+([^(]+)', status) + return ('Up ' + m.group(1).strip()) if m else status.split('(')[0].strip() + if status.startswith('Exited'): + return status.split('(')[0].strip() or status + return status + + def _parse_containers(raw, owner='root'): containers = [] if raw: @@ -453,19 +465,28 @@ def _parse_containers(raw, owner='root'): if '|' not in line: continue parts = line.split('|') + status = parts[1].strip() if len(parts) > 1 else '' containers.append({ - 'name': parts[0].strip(), - 'status': parts[1].strip() if len(parts) > 1 else '', - 'image': parts[2].strip() if len(parts) > 2 else '', - 'ports': parts[3].strip() if len(parts) > 3 else '', - 'owner': owner, + 'name': parts[0].strip(), + 'status': status, + 'image': parts[2].strip() if len(parts) > 2 else '', + 'ports': parts[3].strip() if len(parts) > 3 else '', + 'created_at': parts[4].strip() if len(parts) > 4 else '', + 'uptime': _parse_uptime(status), + 'running': status.startswith('Up'), + 'owner': owner, }) return containers +_DOCKER_PS_FMT = ( + "{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}|{{.CreatedAt}}" +) + + def get_containers(): stdout, _ = _ssh_main( - "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | " + f"docker ps -a --format '{_DOCKER_PS_FMT}' 2>/dev/null | " "grep -E 'frappe|nextcloud|mautic|n8n|odoo'" ) return _parse_containers(stdout) @@ -473,7 +494,7 @@ def get_containers(): def get_all_root_containers(): stdout, _ = _ssh_main( - "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null" + f"docker ps -a --format '{_DOCKER_PS_FMT}' 2>/dev/null" ) return _parse_containers(stdout) @@ -496,7 +517,7 @@ def get_rootless_user_containers_remote(): username = name_out.strip() or f"uid{uid}" ctr_out, _ = _ssh_main( f"DOCKER_HOST=unix://{sock_path} " - f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null" + f"docker ps -a --format '{_DOCKER_PS_FMT}' 2>/dev/null" ) containers.extend(_parse_containers(ctr_out, owner=username)) return containers diff --git a/platform/static/js/platform.js b/platform/static/js/platform.js index 4a18cf7..7c11589 100644 --- a/platform/static/js/platform.js +++ b/platform/static/js/platform.js @@ -4,6 +4,8 @@ let manualBackupJobId = null; let manualBackupPoll = null; let currentJobId = null; let pollInterval = null; +let allContainersCache = []; +let allContainersStats = {}; function el(id) { return document.getElementById(id); } @@ -204,12 +206,8 @@ function updateContainerStatusBadge(name, status) { document.querySelectorAll(`.ctr-status-cell[data-ctr="${name}"]`).forEach((cell) => { cell.innerHTML = statusBadgeHTML(status); }); - document.querySelectorAll('#all-containers-body tr').forEach((row) => { - const nameTd = row.querySelector('.ct-name'); - if (nameTd && nameTd.textContent.trim() === name) { - const statusTd = row.cells[2]; - if (statusTd) statusTd.innerHTML = statusBadgeHTML(status); - } + document.querySelectorAll(`#all-containers-body tr[data-ctr="${name}"] .ctr-status-cell`).forEach((cell) => { + cell.innerHTML = statusBadgeHTML(status); }); } @@ -248,44 +246,129 @@ function buildActionBtns(name) { `; } +function parseCpuPct(cpuStr) { + if (!cpuStr || cpuStr === '—') return 0; + return parseFloat(String(cpuStr).replace('%', '')) || 0; +} + +function parseCreatedTs(createdAt) { + if (!createdAt) return 0; + const t = Date.parse(createdAt); + return Number.isNaN(t) ? 0 : t; +} + +function parseUptimeSeconds(uptimeText) { + if (!uptimeText || !uptimeText.startsWith('Up')) return 0; + const text = uptimeText.replace(/^Up\s+/i, '').toLowerCase(); + let sec = 0; + const dayM = text.match(/(\d+)\s*day/); + const hrM = text.match(/(\d+)\s*hour/); + const minM = text.match(/(\d+)\s*minute/); + const secM = text.match(/(\d+)\s*second/); + if (dayM) sec += parseInt(dayM[1], 10) * 86400; + if (hrM) sec += parseInt(hrM[1], 10) * 3600; + if (minM) sec += parseInt(minM[1], 10) * 60; + if (secM) sec += parseInt(secM[1], 10); + return sec; +} + +function filterAndSortContainers(containers, stats, query, sortBy) { + let list = [...containers]; + const q = (query || '').trim().toLowerCase(); + if (q) { + list = list.filter((c) => + (c.name || '').toLowerCase().includes(q) || + (c.image || '').toLowerCase().includes(q) || + (c.owner || '').toLowerCase().includes(q) || + (c.ports || '').toLowerCase().includes(q) || + (c.uptime || '').toLowerCase().includes(q) + ); + } + list.sort((a, b) => { + switch (sortBy) { + case 'cpu_desc': + return parseCpuPct((stats[b.name] || {}).cpu) - parseCpuPct((stats[a.name] || {}).cpu); + case 'cpu_asc': + return parseCpuPct((stats[a.name] || {}).cpu) - parseCpuPct((stats[b.name] || {}).cpu); + case 'created_desc': + return parseCreatedTs(b.created_at) - parseCreatedTs(a.created_at); + case 'created_asc': + return parseCreatedTs(a.created_at) - parseCreatedTs(b.created_at); + case 'uptime_desc': + return parseUptimeSeconds(b.uptime) - parseUptimeSeconds(a.uptime); + case 'name': + default: + return (a.name || '').localeCompare(b.name || ''); + } + }); + return list; +} + +function renderAllContainersRows(containers, stats) { + const body = el('all-containers-body'); + if (!body) return; + const filterMeta = el('ctr-filter-meta'); + const total = allContainersCache.length; + if (filterMeta) { + filterMeta.textContent = containers.length === total + ? `${total} shown` + : `${containers.length} of ${total} shown`; + } + if (!containers.length) { + body.innerHTML = `
| NAME | OWNER | STATUS | +UP TIME | CPU | MEMORY | NET I/O | @@ -28,7 +114,7 @@ -||||
|---|---|---|---|---|---|---|---|---|---|---|
Loading… | ||||||||||
Loading… | ||||||||||
| NAME | -STATUS | -CPU | -MEMORY | -NET I/O | - - - -ACTIONS | -|||
|---|---|---|---|---|---|---|---|---|
| {{ c.name }} | -- {% if 'Up' in c.status %} - Running - {% else %} - Stopped - {% endif %} - | -— | -- - | -— | - - - -{{ ctr_actions(c.name) }} |
- |||
No containers | ||||||||