feat(ui): Docker container management page — restart, stop, start
This commit is contained in:
parent
1e9710ce0c
commit
e3cdf70883
1 changed files with 307 additions and 0 deletions
307
services/web-ui/public/containers.html
Normal file
307
services/web-ui/public/containers.html
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<title>Containers — Z-AMPP</title>
|
||||
<link rel="stylesheet" href="css/common.css">
|
||||
<style>
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.z-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.z-table th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.z-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.z-table tr:hover td { background: var(--bg-hover); }
|
||||
.z-table .empty-row {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.state-running { background: oklch(22% 0.07 145 / 0.6); color: oklch(68% 0.18 145); border: 1px solid oklch(45% 0.14 145 / 0.5); }
|
||||
.state-exited { background: oklch(15% 0.02 250 / 0.6); color: oklch(50% 0.05 250); border: 1px solid oklch(35% 0.04 250 / 0.5); }
|
||||
.state-paused { background: oklch(22% 0.09 80 / 0.6); color: oklch(68% 0.18 80 ); border: 1px solid oklch(45% 0.14 80 / 0.5); }
|
||||
.state-dead { background: oklch(22% 0.09 25 / 0.6); color: oklch(68% 0.18 25 ); border: 1px solid oklch(45% 0.14 25 / 0.5); }
|
||||
.state-restarting { background: oklch(22% 0.07 266 / 0.6); color: oklch(68% 0.18 266); border: 1px solid oklch(45% 0.14 266 / 0.5); }
|
||||
|
||||
.container-name { font-weight: 500; color: var(--text-primary); }
|
||||
.container-svc { font-size: 10px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 2px; }
|
||||
.container-image { font-family: var(--font-mono); font-size: 11px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.container-ports { font-family: var(--font-mono); font-size: 10px; }
|
||||
|
||||
.refresh-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.refresh-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--signal-warn, oklch(65% 0.18 80));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.refresh-dot.live {
|
||||
background: var(--signal-good, oklch(62% 0.18 145));
|
||||
animation: rd-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rd-pulse { 0%,100%{opacity:.75;} 50%{opacity:1;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<nav class="sidebar" aria-label="Main navigation">
|
||||
<div class="sidebar-brand">
|
||||
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
|
||||
<span class="sidebar-brand-name">Z-AMPP</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="home.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
|
||||
Home
|
||||
</a>
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
Library
|
||||
</a>
|
||||
<a href="projects.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
|
||||
Projects
|
||||
</a>
|
||||
<a href="upload.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||||
Ingest
|
||||
</a>
|
||||
<a href="recorders.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
||||
Recorders
|
||||
</a>
|
||||
<a href="capture.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
||||
Capture
|
||||
</a>
|
||||
<a href="jobs.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
||||
Jobs
|
||||
</a>
|
||||
<a href="editor.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
||||
Editor
|
||||
</a>
|
||||
<div class="sidebar-section-label">Admin</div>
|
||||
<a href="users.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="tokens.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||||
Tokens
|
||||
</a>
|
||||
<a href="containers.html" class="nav-item active">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="9" rx="1"/><path d="M1 5l2-3h10l2 3"/><line x1="5" y1="5" x2="5" y2="14"/><line x1="11" y1="5" x2="11" y2="14"/></svg>
|
||||
Containers
|
||||
</a>
|
||||
<a href="cluster.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="3" r="2"/><circle cx="3" cy="13" r="2"/><circle cx="13" cy="13" r="2"/><line x1="8" y1="5" x2="8" y2="9"/><line x1="8" y1="9" x2="3" y2="11"/><line x1="8" y1="9" x2="13" y2="11"/></svg>
|
||||
Cluster
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name" id="userName">—</div>
|
||||
<div class="sidebar-user-role" id="userRole"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title">Containers</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="refresh-indicator">
|
||||
<span class="refresh-dot" id="refreshDot"></span>
|
||||
<span id="refreshText">Loading…</span>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="refreshBtn" style="margin-left:8px;">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
<table class="z-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container</th>
|
||||
<th>Image</th>
|
||||
<th>State</th>
|
||||
<th>Ports</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="containerBody">
|
||||
<tr><td colspan="5" class="empty-row">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||
|
||||
<script src="js/api.js?v=6"></script>
|
||||
<script>
|
||||
let containers = [];
|
||||
|
||||
async function loadContainers() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/system/containers', { credentials: 'include' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
containers = await r.json();
|
||||
renderContainers();
|
||||
setDot('live', 'Live');
|
||||
} catch (err) {
|
||||
setDot('error', 'Error');
|
||||
console.error('Container load failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContainers() {
|
||||
const tbody = document.getElementById('containerBody');
|
||||
if (!containers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-row">No containers found for this compose project.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = containers.slice().sort((a, b) => (a.service || a.name).localeCompare(b.service || b.name));
|
||||
|
||||
tbody.innerHTML = sorted.map(c => {
|
||||
const state = (c.state || 'exited').toLowerCase();
|
||||
const running = state === 'running';
|
||||
const svcLbl = c.service && c.service !== c.name
|
||||
? `<div class="container-svc">${esc(c.service)}</div>` : '';
|
||||
const actionBtn = running
|
||||
? `<button class="btn btn-ghost btn-sm" onclick="containerAction('${esc(c.id)}','stop',this)" title="Stop container">Stop</button>`
|
||||
: `<button class="btn btn-ghost btn-sm" onclick="containerAction('${esc(c.id)}','start',this)" title="Start container">Start</button>`;
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="container-name">${esc(c.name)}</div>
|
||||
${svcLbl}
|
||||
</td>
|
||||
<td><div class="container-image" title="${esc(c.image)}">${esc(c.image)}</div></td>
|
||||
<td>
|
||||
<span class="state-badge state-${state}">${esc(c.state)}</span>
|
||||
<div style="font-size:10px;color:var(--text-tertiary);margin-top:3px;">${esc(c.status)}</div>
|
||||
</td>
|
||||
<td><span class="container-ports">${c.ports ? esc(c.ports) : '—'}</span></td>
|
||||
<td style="text-align:right">
|
||||
<div style="display:flex;gap:4px;justify-content:flex-end;">
|
||||
${actionBtn}
|
||||
<button class="btn btn-ghost btn-sm" onclick="containerAction('${esc(c.id)}','restart',this)" title="Restart container">Restart</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function containerAction(id, action, btn) {
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`/api/v1/system/containers/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
throw new Error(j.error || 'HTTP ' + r.status);
|
||||
}
|
||||
const label = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
toast(label + ' sent', id, 'success');
|
||||
// Refresh after a brief delay to let the container change state
|
||||
setTimeout(loadContainers, 2500);
|
||||
} catch (err) {
|
||||
toast('Action failed', err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = origText;
|
||||
}
|
||||
}
|
||||
|
||||
function setDot(state, label) {
|
||||
const dot = document.getElementById('refreshDot');
|
||||
const txt = document.getElementById('refreshText');
|
||||
dot.className = 'refresh-dot' + (state === 'live' ? ' live' : '');
|
||||
txt.textContent = label;
|
||||
}
|
||||
|
||||
function toast(title, msg, type) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast--' + (type || 'info');
|
||||
el.innerHTML = '<div class="toast-body"><div class="toast-title">' + esc(title) + '</div>' +
|
||||
(msg ? '<div class="toast-msg">' + esc(msg) + '</div>' : '') + '</div>';
|
||||
document.getElementById('toastContainer').appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContainers();
|
||||
setInterval(loadContainers, 10000);
|
||||
document.getElementById('refreshBtn').onclick = loadContainers;
|
||||
});
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue