Change : dashboard , containers

This commit is contained in:
2026-06-12 21:49:47 +01:00
parent df99603d9e
commit c3e3a3b28c
5 changed files with 480 additions and 190 deletions

View File

@@ -1,5 +1,71 @@
{% extends "base.html" %}
{% 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-header">
<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>
</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;">
<table class="ct-table">
<thead>
@@ -18,6 +103,7 @@
<th>NAME</th>
<th>OWNER</th>
<th>STATUS</th>
<th>UP TIME</th>
<th>CPU</th>
<th>MEMORY</th>
<th>NET I/O</th>
@@ -28,7 +114,7 @@
</tr>
</thead>
<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>
</table>
</div>

View File

@@ -1,16 +1,68 @@
{% extends "base.html" %}
{% block content %}
{% macro ctr_actions(name) %}
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('{{ name }}','restart',this)">
<i class="fas fa-rotate-right"></i>
</button>
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('{{ name }}','stop',this)">
<i class="fas fa-stop"></i>
</button>
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('{{ name }}','start',this)">
<i class="fas fa-play"></i>
</button>
{% endmacro %}
<style>
.dash-charts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.dash-chart-card {
background: var(--surface);
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="metric-card cpu">
@@ -41,8 +93,10 @@
<span class="card-meta" id="overview-meta">Docker {{ system.docker_v or '…' }} · {{ main_server }}</span>
</div>
<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-running">{{ running_count }}</div><div class="stat-label">Running</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" 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-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>
@@ -53,157 +107,196 @@
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="card-meta">Auto-refresh 15s</span>
<button class="btn btn-ghost btn-sm" onclick="toggleExtraColumns('app')" id="app-toggle-btn">
<i class="fas fa-eye"></i> Show more
</button>
<div class="dash-charts">
<div class="dash-chart-card">
<h3><i class="fas fa-cubes"></i> Containers</h3>
<div class="dash-chart-wrap"><canvas id="chart-containers"></canvas></div>
<div class="dash-chart-legend" id="legend-containers"></div>
<div class="dash-quick-links">
<a href="/containers" class="btn btn-ghost btn-sm"><i class="fas fa-layer-group"></i> All Containers</a>
</div>
</div>
<div style="overflow-x:auto;">
<table class="ct-table" id="app-containers-table">
<thead>
<tr>
<th>NAME</th>
<th>STATUS</th>
<th>CPU</th>
<th>MEMORY</th>
<th>NET I/O</th>
<th class="col-extra app-extra" style="display:none;">DISK I/O</th>
<th class="col-extra app-extra" style="display:none;">IMAGE</th>
<th class="col-extra app-extra" style="display:none;">PORTS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody id="app-containers-body">
{% 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 class="dash-chart-card">
<h3><i class="fas fa-globe"></i> Application Sites</h3>
<div class="dash-chart-wrap"><canvas id="chart-sites"></canvas></div>
<div class="dash-chart-legend" id="legend-sites"></div>
<div class="dash-quick-links">
<a href="/sites" class="btn btn-ghost btn-sm"><i class="fas fa-arrow-up-right-from-square"></i> Open Sites</a>
</div>
</div>
<div class="dash-chart-card">
<h3><i class="fas fa-database"></i> Backups & Users</h3>
<div class="dash-chart-wrap"><canvas id="chart-ops"></canvas></div>
<div class="dash-chart-legend" id="legend-ops"></div>
<div class="dash-quick-links">
<a href="/backups" class="btn btn-ghost btn-sm"><i class="fas fa-database"></i> Backups</a>
<a href="/users" class="btn btn-ghost btn-sm"><i class="fas fa-users"></i> Users</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
(function () {
// ── Load cloud backup count async ──────────────────────────────────────────
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(e) {}
}
loadCloudCount();
const charts = {};
const isDark = () => (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
const textColor = () => isDark() ? '#8892a4' : '#4a5578';
const gridColor = () => isDark() ? '#1e2330' : '#dde1ed';
{% if not running_on_main %}
// ── Helper: build a container row ──────────────────────────────────────────
function buildRow(c) {
const isUp = c.status && c.status.includes('Up');
const badge = isUp
? `<span class="badge badge-run">Running</span>`
: `<span class="badge badge-stop">Stopped</span>`;
return `
<tr data-ctr="${c.name}">
<td class="ct-name">${c.name}</td>
<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>
<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 || '—'}</td>
<td><div class="action-btns">
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
<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>`;
function chartDefaults() {
Chart.defaults.color = textColor();
Chart.defaults.borderColor = gridColor();
}
function makeDonut(canvasId, labels, values, colors) {
const canvas = document.getElementById(canvasId);
if (!canvas || typeof Chart === 'undefined') return null;
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(canvas, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '62%',
plugins: {
legend: { display: false },
},
},
});
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) {
if (!s) return;
const cpu = parseFloat(s.cpu_pct) || 0;
document.getElementById('m-cpu').innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
document.getElementById('m-mem').textContent = s.memory || '—';
document.getElementById('m-disk').textContent = s.disk || '—';
document.getElementById('m-load').textContent = s.load || '—';
document.getElementById('g-cpu').style.width = `${Math.min(cpu, 100)}%`;
document.getElementById('g-mem').style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
document.getElementById('g-disk').style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
const mCpu = document.getElementById('m-cpu');
if (mCpu) mCpu.innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
set('m-mem', s.memory || '—');
set('m-disk', s.disk || '—');
set('m-load', s.load || '—');
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) {
const meta = document.getElementById('overview-meta');
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
}
}
function applyStats(data) {
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.textContent = val; };
set('stat-total', data.containers ? data.containers.length : undefined);
set('stat-running', data.running_count);
set('stat-users', data.user_count);
set('stat-local-bk', data.local_backups);
set('stat-vm-bk', data.vm_backups);
function applyStats(d) {
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null) el.textContent = val;
};
set('stat-total', d.container_count);
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) {
if (!containers || !containers.length) return;
const tbody = document.getElementById('app-containers-body');
if (!tbody) return;
tbody.innerHTML = containers.map(buildRow).join('');
const extras = tbody.querySelectorAll('.app-extra');
const btn = document.getElementById('app-toggle-btn');
if (btn && btn.dataset.expanded === 'true') extras.forEach(el => el.style.display = '');
if (typeof refreshContainerStats === 'function') refreshContainerStats();
function applySitesChart(sites) {
if (!sites || !sites.length) {
makeDonut('chart-sites', ['No data'], [1], ['#4a5568']);
renderLegend('legend-sites', [{ label: 'Sites', value: 0, color: '#4a5568' }]);
return;
}
let healthy = 0, unhealthy = 0, offline = 0;
sites.forEach(s => {
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() {
chartDefaults();
try {
const res = await fetch('/api/dashboard');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const [dashRes, sitesRes] = await Promise.all([
fetch('/api/dashboard'),
fetch('/api/sites?health=1'),
]);
if (!dashRes.ok) throw new Error(`HTTP ${dashRes.status}`);
const data = await dashRes.json();
applySystem(data.system);
applyStats(data);
applyContainers(data.containers);
if (sitesRes.ok) {
const sitesData = await sitesRes.json();
applySitesChart(sitesData.sites || []);
}
} catch (err) {
console.error('[dashboard] async load failed:', err);
console.error('[dashboard] load failed:', err);
}
loadCloudCount();
}
if (document.readyState === 'loading') {
@@ -211,7 +304,7 @@
} else {
loadDashboardAsync();
}
{% endif %}
setInterval(loadDashboardAsync, 60000);
})();
</script>
{% endblock %}
{% endblock %}