Change : dashboard , containers
This commit is contained in:
@@ -91,19 +91,23 @@ def dashboard():
|
|||||||
vm_backups = get_vm_backups()
|
vm_backups = get_vm_backups()
|
||||||
|
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
containers = get_containers()
|
root_ctrs = get_all_root_containers()
|
||||||
running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
|
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()
|
system = get_system_info()
|
||||||
users = get_all_users()
|
users = get_all_users()
|
||||||
|
container_count = len(all_ctrs)
|
||||||
else:
|
else:
|
||||||
containers = []
|
|
||||||
running_count = 0
|
running_count = 0
|
||||||
system = {}
|
system = {}
|
||||||
users = []
|
users = []
|
||||||
|
container_count = 0
|
||||||
|
|
||||||
return render_template('pages/dashboard.html',
|
return render_template('pages/dashboard.html',
|
||||||
containers=containers,
|
container_count=container_count,
|
||||||
running_count=running_count,
|
running_count=running_count,
|
||||||
|
site_count=len(SITES),
|
||||||
backups=backups,
|
backups=backups,
|
||||||
vm_backups=vm_backups,
|
vm_backups=vm_backups,
|
||||||
main_server=MAIN_SERVER_IP,
|
main_server=MAIN_SERVER_IP,
|
||||||
@@ -295,15 +299,18 @@ def api_dashboard():
|
|||||||
user_ctrs = get_rootless_user_containers_remote()
|
user_ctrs = get_rootless_user_containers_remote()
|
||||||
all_ctrs = root_ctrs + user_ctrs
|
all_ctrs = root_ctrs + user_ctrs
|
||||||
users = get_all_users()
|
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({
|
return jsonify({
|
||||||
'system': system,
|
'system': system,
|
||||||
'containers': all_ctrs,
|
'container_count': len(all_ctrs),
|
||||||
'running_count': running,
|
'running_count': running,
|
||||||
'user_count': len(users),
|
'stopped_count': stopped,
|
||||||
'local_backups': len(get_local_backups()),
|
'sites_count': len(SITES),
|
||||||
'vm_backups': len(get_vm_backups()),
|
'user_count': len(users),
|
||||||
|
'local_backups': len(get_local_backups()),
|
||||||
|
'vm_backups': len(get_vm_backups()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -446,6 +446,18 @@ def get_backup_script_path():
|
|||||||
# CONTAINERS
|
# 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'):
|
def _parse_containers(raw, owner='root'):
|
||||||
containers = []
|
containers = []
|
||||||
if raw:
|
if raw:
|
||||||
@@ -453,19 +465,28 @@ def _parse_containers(raw, owner='root'):
|
|||||||
if '|' not in line:
|
if '|' not in line:
|
||||||
continue
|
continue
|
||||||
parts = line.split('|')
|
parts = line.split('|')
|
||||||
|
status = parts[1].strip() if len(parts) > 1 else ''
|
||||||
containers.append({
|
containers.append({
|
||||||
'name': parts[0].strip(),
|
'name': parts[0].strip(),
|
||||||
'status': parts[1].strip() if len(parts) > 1 else '',
|
'status': status,
|
||||||
'image': parts[2].strip() if len(parts) > 2 else '',
|
'image': parts[2].strip() if len(parts) > 2 else '',
|
||||||
'ports': parts[3].strip() if len(parts) > 3 else '',
|
'ports': parts[3].strip() if len(parts) > 3 else '',
|
||||||
'owner': owner,
|
'created_at': parts[4].strip() if len(parts) > 4 else '',
|
||||||
|
'uptime': _parse_uptime(status),
|
||||||
|
'running': status.startswith('Up'),
|
||||||
|
'owner': owner,
|
||||||
})
|
})
|
||||||
return containers
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
_DOCKER_PS_FMT = (
|
||||||
|
"{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}|{{.CreatedAt}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_containers():
|
def get_containers():
|
||||||
stdout, _ = _ssh_main(
|
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'"
|
"grep -E 'frappe|nextcloud|mautic|n8n|odoo'"
|
||||||
)
|
)
|
||||||
return _parse_containers(stdout)
|
return _parse_containers(stdout)
|
||||||
@@ -473,7 +494,7 @@ def get_containers():
|
|||||||
|
|
||||||
def get_all_root_containers():
|
def get_all_root_containers():
|
||||||
stdout, _ = _ssh_main(
|
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)
|
return _parse_containers(stdout)
|
||||||
|
|
||||||
@@ -496,7 +517,7 @@ def get_rootless_user_containers_remote():
|
|||||||
username = name_out.strip() or f"uid{uid}"
|
username = name_out.strip() or f"uid{uid}"
|
||||||
ctr_out, _ = _ssh_main(
|
ctr_out, _ = _ssh_main(
|
||||||
f"DOCKER_HOST=unix://{sock_path} "
|
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))
|
containers.extend(_parse_containers(ctr_out, owner=username))
|
||||||
return containers
|
return containers
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ let manualBackupJobId = null;
|
|||||||
let manualBackupPoll = null;
|
let manualBackupPoll = null;
|
||||||
let currentJobId = null;
|
let currentJobId = null;
|
||||||
let pollInterval = null;
|
let pollInterval = null;
|
||||||
|
let allContainersCache = [];
|
||||||
|
let allContainersStats = {};
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
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) => {
|
document.querySelectorAll(`.ctr-status-cell[data-ctr="${name}"]`).forEach((cell) => {
|
||||||
cell.innerHTML = statusBadgeHTML(status);
|
cell.innerHTML = statusBadgeHTML(status);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('#all-containers-body tr').forEach((row) => {
|
document.querySelectorAll(`#all-containers-body tr[data-ctr="${name}"] .ctr-status-cell`).forEach((cell) => {
|
||||||
const nameTd = row.querySelector('.ct-name');
|
cell.innerHTML = statusBadgeHTML(status);
|
||||||
if (nameTd && nameTd.textContent.trim() === name) {
|
|
||||||
const statusTd = row.cells[2];
|
|
||||||
if (statusTd) statusTd.innerHTML = statusBadgeHTML(status);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,44 +246,129 @@ function buildActionBtns(name) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = `<tr><td colspan="11"><div class="empty-state"><i class="fas fa-inbox"></i>${total ? 'No matches' : 'No containers'}</div></td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const showExtra = extraState.all;
|
||||||
|
const ed = showExtra ? '' : 'display:none;';
|
||||||
|
body.innerHTML = containers.map((c) => {
|
||||||
|
const up = c.running || (c.status && c.status.includes('Up'));
|
||||||
|
const s = stats[c.name] || {};
|
||||||
|
const pct = parseFloat(s.mem_pct) || 0;
|
||||||
|
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
||||||
|
const uptimeCls = up ? 'ct-uptime' : 'ct-uptime stopped';
|
||||||
|
const uptimeText = c.uptime || (up ? c.status : c.status || '—');
|
||||||
|
return `<tr data-ctr="${escapeHtml(c.name)}">
|
||||||
|
<td class="ct-name">${escapeHtml(c.name)}</td>
|
||||||
|
<td class="ct-owner">${escapeHtml(c.owner || 'root')}</td>
|
||||||
|
<td class="ctr-status-cell" data-ctr="${escapeHtml(c.name)}">${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
|
||||||
|
<td><span class="${uptimeCls}">${escapeHtml(uptimeText)}</span></td>
|
||||||
|
<td><span class="stat-pct">${escapeHtml(s.cpu || '—')}</span></td>
|
||||||
|
<td><div class="stat-bar-wrap"><div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct, 100)}%"></div></div><span class="stat-pct">${escapeHtml(s.mem_pct || '—')}</span></div></td>
|
||||||
|
<td><span class="stat-pct" style="color:var(--cyan)">${escapeHtml(s.net || '—')}</span></td>
|
||||||
|
<td class="col-extra all-extra" style="${ed}"><span class="stat-pct" style="color:var(--yellow)">${escapeHtml(s.block || '—')}</span></td>
|
||||||
|
<td class="col-extra all-extra ct-image" style="${ed}">${escapeHtml(c.image)}</td>
|
||||||
|
<td class="col-extra all-extra ct-ports" style="${ed}">${escapeHtml(c.ports || '—')}</td>
|
||||||
|
<td>${buildActionBtns(c.name)}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyContainersFilter() {
|
||||||
|
const query = el('ctr-search')?.value || '';
|
||||||
|
const sortBy = el('ctr-sort')?.value || 'name';
|
||||||
|
const filtered = filterAndSortContainers(allContainersCache, allContainersStats, query, sortBy);
|
||||||
|
renderAllContainersRows(filtered, allContainersStats);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAllContainers() {
|
async function loadAllContainers() {
|
||||||
const body = el('all-containers-body');
|
const body = el('all-containers-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const meta = el('all-ctr-meta');
|
const meta = el('all-ctr-meta');
|
||||||
body.innerHTML = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
body.innerHTML = '<tr><td colspan="11"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
||||||
try {
|
try {
|
||||||
const [ctrRes, statRes] = await Promise.all([fetch('/api/containers/all'), fetch('/api/stats')]);
|
const [ctrRes, statRes] = await Promise.all([fetch('/api/containers/all'), fetch('/api/stats')]);
|
||||||
const { containers, running } = await ctrRes.json();
|
const { containers, running } = await ctrRes.json();
|
||||||
const stats = await statRes.json();
|
allContainersCache = containers || [];
|
||||||
if (meta) meta.textContent = `${containers.length} total · ${running} running`;
|
allContainersStats = await statRes.json();
|
||||||
if (el('nav-badge-containers')) el('nav-badge-containers').textContent = containers.length;
|
if (meta) meta.textContent = `${allContainersCache.length} total · ${running} running`;
|
||||||
if (!containers.length) {
|
if (el('nav-badge-containers')) el('nav-badge-containers').textContent = allContainersCache.length;
|
||||||
body.innerHTML = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
applyContainersFilter();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const showExtra = extraState.all;
|
|
||||||
body.innerHTML = containers.map((c) => {
|
|
||||||
const up = c.status.includes('Up');
|
|
||||||
const s = stats[c.name] || {};
|
|
||||||
const pct = parseFloat(s.mem_pct) || 0;
|
|
||||||
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
|
||||||
const ed = showExtra ? '' : 'display:none;';
|
|
||||||
return `<tr>
|
|
||||||
<td class="ct-name">${c.name}</td><td class="ct-owner">${c.owner}</td>
|
|
||||||
<td>${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
|
|
||||||
<td><span class="stat-pct">${s.cpu || '—'}</span></td>
|
|
||||||
<td><div class="stat-bar-wrap"><div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct,100)}%"></div></div><span class="stat-pct">${s.mem_pct || '—'}</span></div></td>
|
|
||||||
<td><span class="stat-pct" style="color:var(--cyan)">${s.net || '—'}</span></td>
|
|
||||||
<td class="col-extra all-extra" style="${ed}"><span class="stat-pct" style="color:var(--yellow)">${s.block || '—'}</span></td>
|
|
||||||
<td class="col-extra all-extra ct-image" style="${ed}">${c.image}</td>
|
|
||||||
<td class="col-extra all-extra ct-ports" style="${ed}">${c.ports || '—'}</td>
|
|
||||||
<td>${buildActionBtns(c.name)}</td></tr>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = `<tr><td colspan="10"><div class="empty-state">${e}</div></td></tr>`;
|
body.innerHTML = `<tr><td colspan="11"><div class="empty-state">${escapeHtml(String(e))}</div></td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.applyContainersFilter = applyContainersFilter;
|
||||||
|
|
||||||
async function refreshBackupsList() {
|
async function refreshBackupsList() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/backups');
|
const r = await fetch('/api/backups');
|
||||||
|
|||||||
@@ -1,5 +1,71 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ctr-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ctr-search-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ctr-search-wrap i {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ctr-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px 9px 34px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ctr-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ctr-search::placeholder { color: var(--text3); }
|
||||||
|
.ctr-sort-select {
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ctr-filter-meta {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.ct-uptime {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ct-uptime.stopped { color: var(--text3); }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
|
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
|
||||||
@@ -11,6 +77,25 @@
|
|||||||
<button class="btn btn-ghost btn-sm" onclick="loadAllContainers()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
<button class="btn btn-ghost btn-sm" onclick="loadAllContainers()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ctr-toolbar">
|
||||||
|
<div class="ctr-search-wrap">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="search" class="ctr-search" id="ctr-search"
|
||||||
|
placeholder="Search by name, image, owner, port…"
|
||||||
|
oninput="applyContainersFilter()" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<select class="ctr-sort-select" id="ctr-sort" onchange="applyContainersFilter()" title="Sort containers">
|
||||||
|
<option value="name">Sort: Name (A→Z)</option>
|
||||||
|
<option value="created_desc">Sort: Newest first</option>
|
||||||
|
<option value="created_asc">Sort: Oldest first</option>
|
||||||
|
<option value="cpu_desc">Sort: CPU (high→low)</option>
|
||||||
|
<option value="cpu_asc">Sort: CPU (low→high)</option>
|
||||||
|
<option value="uptime_desc">Sort: Longest uptime</option>
|
||||||
|
</select>
|
||||||
|
<span class="ctr-filter-meta" id="ctr-filter-meta"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="ct-table">
|
<table class="ct-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -18,6 +103,7 @@
|
|||||||
<th>NAME</th>
|
<th>NAME</th>
|
||||||
<th>OWNER</th>
|
<th>OWNER</th>
|
||||||
<th>STATUS</th>
|
<th>STATUS</th>
|
||||||
|
<th>UP TIME</th>
|
||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
<th>MEMORY</th>
|
<th>MEMORY</th>
|
||||||
<th>NET I/O</th>
|
<th>NET I/O</th>
|
||||||
@@ -28,7 +114,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="all-containers-body">
|
<tbody id="all-containers-body">
|
||||||
<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
|
<tr><td colspan="11"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,68 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% macro ctr_actions(name) %}
|
|
||||||
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('{{ name }}','restart',this)">
|
<style>
|
||||||
<i class="fas fa-rotate-right"></i>
|
.dash-charts {
|
||||||
</button>
|
display: grid;
|
||||||
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('{{ name }}','stop',this)">
|
grid-template-columns: repeat(3, 1fr);
|
||||||
<i class="fas fa-stop"></i>
|
gap: 16px;
|
||||||
</button>
|
margin-bottom: 16px;
|
||||||
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('{{ name }}','start',this)">
|
}
|
||||||
<i class="fas fa-play"></i>
|
.dash-chart-card {
|
||||||
</button>
|
background: var(--surface);
|
||||||
{% endmacro %}
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
.dash-chart-card h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text2);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dash-chart-card h3 i { color: var(--accent2); font-size: 11px; }
|
||||||
|
.dash-chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dash-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.dash-chart-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.dash-chart-legend .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.dash-quick-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.dash-charts { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="metrics-row">
|
<div class="metrics-row">
|
||||||
<div class="metric-card cpu">
|
<div class="metric-card cpu">
|
||||||
@@ -41,8 +93,10 @@
|
|||||||
<span class="card-meta" id="overview-meta">Docker {{ system.docker_v or '…' }} · {{ main_server }}</span>
|
<span class="card-meta" id="overview-meta">Docker {{ system.docker_v or '…' }} · {{ main_server }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-total">{{ containers|length }}</div><div class="stat-label">App Containers</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-total">{{ container_count }}</div><div class="stat-label">All Containers</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-running">{{ running_count }}</div><div class="stat-label">Running</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-running" style="color:var(--green);">{{ running_count }}</div><div class="stat-label">Running</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number" id="stat-stopped" style="color:var(--red);">{{ container_count - running_count }}</div><div class="stat-label">Stopped</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number" id="stat-sites" style="color:var(--purple);">{{ site_count }}</div><div class="stat-label">App Sites</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
|
||||||
@@ -53,157 +107,196 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="dash-charts">
|
||||||
<div class="card-header">
|
<div class="dash-chart-card">
|
||||||
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
|
<h3><i class="fas fa-cubes"></i> Containers</h3>
|
||||||
<div style="display:flex;align-items:center;gap:10px;">
|
<div class="dash-chart-wrap"><canvas id="chart-containers"></canvas></div>
|
||||||
<span class="card-meta">Auto-refresh 15s</span>
|
<div class="dash-chart-legend" id="legend-containers"></div>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="toggleExtraColumns('app')" id="app-toggle-btn">
|
<div class="dash-quick-links">
|
||||||
<i class="fas fa-eye"></i> Show more
|
<a href="/containers" class="btn btn-ghost btn-sm"><i class="fas fa-layer-group"></i> All Containers</a>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto;">
|
<div class="dash-chart-card">
|
||||||
<table class="ct-table" id="app-containers-table">
|
<h3><i class="fas fa-globe"></i> Application Sites</h3>
|
||||||
<thead>
|
<div class="dash-chart-wrap"><canvas id="chart-sites"></canvas></div>
|
||||||
<tr>
|
<div class="dash-chart-legend" id="legend-sites"></div>
|
||||||
<th>NAME</th>
|
<div class="dash-quick-links">
|
||||||
<th>STATUS</th>
|
<a href="/sites" class="btn btn-ghost btn-sm"><i class="fas fa-arrow-up-right-from-square"></i> Open Sites</a>
|
||||||
<th>CPU</th>
|
</div>
|
||||||
<th>MEMORY</th>
|
</div>
|
||||||
<th>NET I/O</th>
|
<div class="dash-chart-card">
|
||||||
<th class="col-extra app-extra" style="display:none;">DISK I/O</th>
|
<h3><i class="fas fa-database"></i> Backups & Users</h3>
|
||||||
<th class="col-extra app-extra" style="display:none;">IMAGE</th>
|
<div class="dash-chart-wrap"><canvas id="chart-ops"></canvas></div>
|
||||||
<th class="col-extra app-extra" style="display:none;">PORTS</th>
|
<div class="dash-chart-legend" id="legend-ops"></div>
|
||||||
<th>ACTIONS</th>
|
<div class="dash-quick-links">
|
||||||
</tr>
|
<a href="/backups" class="btn btn-ghost btn-sm"><i class="fas fa-database"></i> Backups</a>
|
||||||
</thead>
|
<a href="/users" class="btn btn-ghost btn-sm"><i class="fas fa-users"></i> Users</a>
|
||||||
<tbody id="app-containers-body">
|
</div>
|
||||||
{% for c in containers %}
|
|
||||||
<tr data-ctr="{{ c.name }}">
|
|
||||||
<td class="ct-name">{{ c.name }}</td>
|
|
||||||
<td class="ctr-status-cell" data-ctr="{{ c.name }}">
|
|
||||||
{% if 'Up' in c.status %}
|
|
||||||
<span class="badge badge-run">Running</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-stop">Stopped</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="cpu">—</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="stat-bar-wrap">
|
|
||||||
<div class="stat-bar-bg"><div class="stat-bar-fill" data-ctr="{{ c.name }}" data-stat="mem_bar" style="width:0%"></div></div>
|
|
||||||
<span class="stat-pct" data-ctr="{{ c.name }}" data-stat="mem_pct">—</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="net" style="color:var(--cyan)">—</span></td>
|
|
||||||
<td class="col-extra app-extra" style="display:none;"><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="block" style="color:var(--yellow)">—</span></td>
|
|
||||||
<td class="col-extra app-extra ct-image" style="display:none;">{{ c.image }}</td>
|
|
||||||
<td class="col-extra app-extra ct-ports" style="display:none;">{{ c.ports or '—' }}</td>
|
|
||||||
<td><div class="action-btns">{{ ctr_actions(c.name) }}</div></td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr id="empty-row"><td colspan="9"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// ── Load cloud backup count async ──────────────────────────────────────────
|
const charts = {};
|
||||||
async function loadCloudCount() {
|
const isDark = () => (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
|
||||||
try {
|
const textColor = () => isDark() ? '#8892a4' : '#4a5578';
|
||||||
const res = await fetch('/api/cloud/r2/stats');
|
const gridColor = () => isDark() ? '#1e2330' : '#dde1ed';
|
||||||
const data = await res.json();
|
|
||||||
const el = document.getElementById('stat-cloud-bk');
|
|
||||||
if (el) el.textContent = data.count ?? '—';
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
loadCloudCount();
|
|
||||||
|
|
||||||
{% if not running_on_main %}
|
function chartDefaults() {
|
||||||
// ── Helper: build a container row ──────────────────────────────────────────
|
Chart.defaults.color = textColor();
|
||||||
function buildRow(c) {
|
Chart.defaults.borderColor = gridColor();
|
||||||
const isUp = c.status && c.status.includes('Up');
|
}
|
||||||
const badge = isUp
|
|
||||||
? `<span class="badge badge-run">Running</span>`
|
function makeDonut(canvasId, labels, values, colors) {
|
||||||
: `<span class="badge badge-stop">Stopped</span>`;
|
const canvas = document.getElementById(canvasId);
|
||||||
return `
|
if (!canvas || typeof Chart === 'undefined') return null;
|
||||||
<tr data-ctr="${c.name}">
|
if (charts[canvasId]) {
|
||||||
<td class="ct-name">${c.name}</td>
|
charts[canvasId].destroy();
|
||||||
<td class="ctr-status-cell" data-ctr="${c.name}">${badge}</td>
|
}
|
||||||
<td><span class="stat-pct" data-ctr="${c.name}" data-stat="cpu">—</span></td>
|
charts[canvasId] = new Chart(canvas, {
|
||||||
<td>
|
type: 'doughnut',
|
||||||
<div class="stat-bar-wrap">
|
data: {
|
||||||
<div class="stat-bar-bg">
|
labels,
|
||||||
<div class="stat-bar-fill" data-ctr="${c.name}" data-stat="mem_bar" style="width:0%"></div>
|
datasets: [{
|
||||||
</div>
|
data: values,
|
||||||
<span class="stat-pct" data-ctr="${c.name}" data-stat="mem_pct">—</span>
|
backgroundColor: colors,
|
||||||
</div>
|
borderWidth: 0,
|
||||||
</td>
|
hoverOffset: 4,
|
||||||
<td><span class="stat-pct" data-ctr="${c.name}" data-stat="net" style="color:var(--cyan)">—</span></td>
|
}],
|
||||||
<td class="col-extra app-extra" style="display:none;">
|
},
|
||||||
<span class="stat-pct" data-ctr="${c.name}" data-stat="block" style="color:var(--yellow)">—</span>
|
options: {
|
||||||
</td>
|
responsive: true,
|
||||||
<td class="col-extra app-extra ct-image" style="display:none;">${c.image || ''}</td>
|
maintainAspectRatio: true,
|
||||||
<td class="col-extra app-extra ct-ports" style="display:none;">${c.ports || '—'}</td>
|
cutout: '62%',
|
||||||
<td><div class="action-btns">
|
plugins: {
|
||||||
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
|
legend: { display: false },
|
||||||
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${c.name}','stop',this)"><i class="fas fa-stop"></i></button>
|
},
|
||||||
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${c.name}','start',this)"><i class="fas fa-play"></i></button>
|
},
|
||||||
</div></td>
|
});
|
||||||
</tr>`;
|
return charts[canvasId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend(elId, items) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = items.map(i =>
|
||||||
|
`<span><span class="dot" style="background:${i.color}"></span>${i.label}: ${i.value}</span>`
|
||||||
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySystem(s) {
|
function applySystem(s) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
const cpu = parseFloat(s.cpu_pct) || 0;
|
const cpu = parseFloat(s.cpu_pct) || 0;
|
||||||
document.getElementById('m-cpu').innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
|
const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
|
||||||
document.getElementById('m-mem').textContent = s.memory || '—';
|
const mCpu = document.getElementById('m-cpu');
|
||||||
document.getElementById('m-disk').textContent = s.disk || '—';
|
if (mCpu) mCpu.innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
|
||||||
document.getElementById('m-load').textContent = s.load || '—';
|
set('m-mem', s.memory || '—');
|
||||||
document.getElementById('g-cpu').style.width = `${Math.min(cpu, 100)}%`;
|
set('m-disk', s.disk || '—');
|
||||||
document.getElementById('g-mem').style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
|
set('m-load', s.load || '—');
|
||||||
document.getElementById('g-disk').style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
|
const gCpu = document.getElementById('g-cpu');
|
||||||
|
const gMem = document.getElementById('g-mem');
|
||||||
|
const gDisk = document.getElementById('g-disk');
|
||||||
|
if (gCpu) gCpu.style.width = `${Math.min(cpu, 100)}%`;
|
||||||
|
if (gMem) gMem.style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
|
||||||
|
if (gDisk) gDisk.style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
|
||||||
if (s.docker_v) {
|
if (s.docker_v) {
|
||||||
const meta = document.getElementById('overview-meta');
|
const meta = document.getElementById('overview-meta');
|
||||||
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
|
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStats(data) {
|
function applyStats(d) {
|
||||||
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.textContent = val; };
|
const set = (id, val) => {
|
||||||
set('stat-total', data.containers ? data.containers.length : undefined);
|
const el = document.getElementById(id);
|
||||||
set('stat-running', data.running_count);
|
if (el && val !== undefined && val !== null) el.textContent = val;
|
||||||
set('stat-users', data.user_count);
|
};
|
||||||
set('stat-local-bk', data.local_backups);
|
set('stat-total', d.container_count);
|
||||||
set('stat-vm-bk', data.vm_backups);
|
set('stat-running', d.running_count);
|
||||||
|
set('stat-stopped', d.stopped_count);
|
||||||
|
set('stat-sites', d.sites_count);
|
||||||
|
set('stat-users', d.user_count);
|
||||||
|
set('stat-local-bk', d.local_backups);
|
||||||
|
set('stat-vm-bk', d.vm_backups);
|
||||||
|
|
||||||
|
chartDefaults();
|
||||||
|
const running = d.running_count || 0;
|
||||||
|
const stopped = d.stopped_count || 0;
|
||||||
|
makeDonut('chart-containers', ['Running', 'Stopped'], [running, stopped], ['#22c55e', '#ef4444']);
|
||||||
|
renderLegend('legend-containers', [
|
||||||
|
{ label: 'Running', value: running, color: '#22c55e' },
|
||||||
|
{ label: 'Stopped', value: stopped, color: '#ef4444' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const local = d.local_backups || 0;
|
||||||
|
const vm = d.vm_backups || 0;
|
||||||
|
const users = d.user_count || 0;
|
||||||
|
makeDonut('chart-ops', ['Local', 'VM', 'Users'], [local, vm, users], ['#3b82f6', '#a78bfa', '#22d3ee']);
|
||||||
|
renderLegend('legend-ops', [
|
||||||
|
{ label: 'Local backups', value: local, color: '#3b82f6' },
|
||||||
|
{ label: 'VM backups', value: vm, color: '#a78bfa' },
|
||||||
|
{ label: 'Linux users', value: users, color: '#22d3ee' },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyContainers(containers) {
|
function applySitesChart(sites) {
|
||||||
if (!containers || !containers.length) return;
|
if (!sites || !sites.length) {
|
||||||
const tbody = document.getElementById('app-containers-body');
|
makeDonut('chart-sites', ['No data'], [1], ['#4a5568']);
|
||||||
if (!tbody) return;
|
renderLegend('legend-sites', [{ label: 'Sites', value: 0, color: '#4a5568' }]);
|
||||||
tbody.innerHTML = containers.map(buildRow).join('');
|
return;
|
||||||
const extras = tbody.querySelectorAll('.app-extra');
|
}
|
||||||
const btn = document.getElementById('app-toggle-btn');
|
let healthy = 0, unhealthy = 0, offline = 0;
|
||||||
if (btn && btn.dataset.expanded === 'true') extras.forEach(el => el.style.display = '');
|
sites.forEach(s => {
|
||||||
if (typeof refreshContainerStats === 'function') refreshContainerStats();
|
if (!s.health) return;
|
||||||
|
if (s.health.status === 'up') healthy++;
|
||||||
|
else if (s.health.status === 'offline') offline++;
|
||||||
|
else unhealthy++;
|
||||||
|
});
|
||||||
|
const unchecked = sites.length - healthy - unhealthy - offline;
|
||||||
|
const labels = ['Healthy', 'Unhealthy', 'Offline'];
|
||||||
|
const values = [healthy, unhealthy, offline];
|
||||||
|
const colors = ['#22c55e', '#ef4444', '#6b7280'];
|
||||||
|
if (unchecked > 0) {
|
||||||
|
labels.push('Pending');
|
||||||
|
values.push(unchecked);
|
||||||
|
colors.push('#3b82f6');
|
||||||
|
}
|
||||||
|
makeDonut('chart-sites', labels, values, colors);
|
||||||
|
renderLegend('legend-sites', labels.map((l, i) => ({
|
||||||
|
label: l, value: values[i], color: colors[i],
|
||||||
|
})));
|
||||||
|
const el = document.getElementById('stat-sites-healthy');
|
||||||
|
if (el) el.textContent = healthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCloudCount() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/stats');
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('stat-cloud-bk');
|
||||||
|
if (el) el.textContent = data.count ?? '—';
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboardAsync() {
|
async function loadDashboardAsync() {
|
||||||
|
chartDefaults();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/dashboard');
|
const [dashRes, sitesRes] = await Promise.all([
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
fetch('/api/dashboard'),
|
||||||
const data = await res.json();
|
fetch('/api/sites?health=1'),
|
||||||
|
]);
|
||||||
|
if (!dashRes.ok) throw new Error(`HTTP ${dashRes.status}`);
|
||||||
|
const data = await dashRes.json();
|
||||||
applySystem(data.system);
|
applySystem(data.system);
|
||||||
applyStats(data);
|
applyStats(data);
|
||||||
applyContainers(data.containers);
|
if (sitesRes.ok) {
|
||||||
|
const sitesData = await sitesRes.json();
|
||||||
|
applySitesChart(sitesData.sites || []);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[dashboard] async load failed:', err);
|
console.error('[dashboard] load failed:', err);
|
||||||
}
|
}
|
||||||
|
loadCloudCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
@@ -211,7 +304,7 @@
|
|||||||
} else {
|
} else {
|
||||||
loadDashboardAsync();
|
loadDashboardAsync();
|
||||||
}
|
}
|
||||||
{% endif %}
|
setInterval(loadDashboardAsync, 60000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user