Complete Track C roadmap: dashboard, job follow, actions, activity, roles
CI / test (push) Successful in 9m55s

Ship the modern dashboard, job tray log follow, container overflow actions, stacks search, activity history, role-gated controls, invite form with copy token, first-connect checklist, and mark Track C done on the roadmap.
This commit is contained in:
2026-07-10 22:31:16 -04:00
parent 2d8b24ec0e
commit a42f5e4dcf
7 changed files with 1350 additions and 523 deletions
+276 -167
View File
@@ -925,11 +925,16 @@ async function loadAccessView() {
peersEl.innerHTML = html;
peersEl.querySelectorAll('.access-revoke').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive('Revoke peer', 'Revoke this peer? They will be disconnected.')
: true;
if (!ok) return;
await manager.request(Methods.revokePeer, { peerId: btn.dataset.id });
if (typeof showAlert === 'function') showAlert('warning', 'Peer revoked');
loadAccessView();
});
});
applyRoleUI();
}
} catch (err) {
if (peersEl) peersEl.textContent = err.message || 'Failed to list peers';
@@ -979,6 +984,10 @@ async function loadAccessView() {
});
vaultEl.querySelectorAll('.vault-del').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive('Delete credential', 'Remove this vault credential?')
: true;
if (!ok) return;
await manager.request(Methods.vaultDeleteCredential, { id: btn.dataset.id });
loadAccessView();
});
@@ -988,32 +997,158 @@ async function loadAccessView() {
} catch (err) {
if (vaultEl) vaultEl.textContent = err.message || 'Vault unavailable';
}
applyRoleUI();
}
const ROLE_RANK = { viewer: 1, operator: 2, admin: 3 };
/** Hide/disable UI that exceeds the active peer role */
function applyRoleUI() {
const role = manager.active?.role || 'viewer';
const rank = ROLE_RANK[role] || 0;
document.body.dataset.role = role;
document.querySelectorAll('[data-min-role]').forEach((el) => {
const need = ROLE_RANK[el.dataset.minRole] || 99;
const allowed = rank >= need;
el.classList.toggle('role-hidden', !allowed);
if ('disabled' in el) el.disabled = !allowed;
el.setAttribute('aria-disabled', allowed ? 'false' : 'true');
if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`;
});
}
function showFirstConnectChecklist() {
try {
if (localStorage.getItem('peardock.firstConnect.dismissed') === '1') return;
} catch {
// ignore
}
document.getElementById('first-connect-checklist')?.classList.remove('hidden');
}
function dismissFirstConnectChecklist() {
document.getElementById('first-connect-checklist')?.classList.add('hidden');
try {
localStorage.setItem('peardock.firstConnect.dismissed', '1');
} catch {
// ignore
}
}
function renderActivityPanel() {
const list = document.getElementById('activity-panel-list');
if (!list) return;
const jobs = window.peardockOps?.listJobs?.(15) || [];
if (!jobs.length) {
list.innerHTML = '<div class="text-muted small p-3">No jobs yet — deploy, pull, or build to see activity here.</div>';
return;
}
list.innerHTML = jobs
.map((j) => {
const tone =
j.status === 'success' ? 'success' : j.status === 'error' ? 'danger' : 'primary';
const when = j.createdAt ? new Date(j.createdAt).toLocaleTimeString() : '';
const steps = (j.steps || [])
.map((s) => s.status)
.filter(Boolean)
.join(' · ');
return `<button type="button" class="activity-item" data-job-id="${escapeHtmlLite(j.id)}">
<span class="activity-item-kind">${escapeHtmlLite(j.kind || 'Job')}</span>
<span class="badge bg-${tone}">${escapeHtmlLite(j.status || '—')}</span>
<span class="activity-item-meta text-muted">${escapeHtmlLite(when)}${steps ? ` · ${escapeHtmlLite(steps)}` : ''}</span>
</button>`;
})
.join('');
list.querySelectorAll('.activity-item').forEach((btn) => {
btn.addEventListener('click', () => {
const job = window.peardockOps?.listJobs?.(50)?.find((x) => x.id === btn.dataset.jobId);
if (job && window.peardockOps?.showJob) {
window.peardockOps.showJob(job);
document.getElementById('activity-panel')?.classList.add('hidden');
}
});
});
}
window.renderActivityPanel = renderActivityPanel;
window.applyRoleUI = applyRoleUI;
// Wire access view actions once
document.addEventListener('DOMContentLoaded', () => {
const inviteBtn = document.getElementById('access-invite-btn');
if (inviteBtn) {
inviteBtn.addEventListener('click', async () => {
// Invite form (role / TTL / max uses + copy token)
const inviteForm = document.getElementById('invite-peer-form');
if (inviteForm) {
inviteForm.addEventListener('submit', async (e) => {
e.preventDefault();
const role = document.getElementById('invite-role')?.value || 'operator';
const ttlHours = Number(document.getElementById('invite-ttl')?.value) || 72;
const maxUses = Number(document.getElementById('invite-max-uses')?.value) || 1;
try {
const res = await manager.request(Methods.invitePeer, { role: 'operator', ttlHours: 72, maxUses: 1 });
const res = await manager.request(Methods.invitePeer, { role, ttlHours, maxUses });
const token = res?.data?.token;
if (token && typeof showAlert === 'function') {
showAlert('success', `Invite token: ${token}`);
}
if (token && navigator.clipboard?.writeText) {
const resultBox = document.getElementById('invite-token-result');
const tokenInput = document.getElementById('invite-token-value');
if (token && tokenInput) {
tokenInput.value = token;
resultBox?.classList.remove('hidden');
showAlert('success', 'Invite created — copy the token below');
try {
await navigator.clipboard.writeText(token);
await navigator.clipboard?.writeText?.(token);
showAlert('info', 'Token copied to clipboard');
} catch {
// ignore
}
}
loadAccessView();
} catch (err) {
if (typeof showAlert === 'function') showAlert('danger', err.message);
showAlert('danger', err.message || 'Failed to create invite');
}
});
}
document.getElementById('invite-token-copy')?.addEventListener('click', async () => {
const token = document.getElementById('invite-token-value')?.value;
if (!token) return;
try {
await navigator.clipboard.writeText(token);
showAlert('success', 'Token copied');
} catch {
showAlert('warning', 'Could not copy — select and copy manually');
}
});
document.getElementById('invitePeerModal')?.addEventListener('show.bs.modal', () => {
document.getElementById('invite-token-result')?.classList.add('hidden');
const tokenInput = document.getElementById('invite-token-value');
if (tokenInput) tokenInput.value = '';
});
// First-connect checklist
document.getElementById('first-connect-dismiss')?.addEventListener('click', dismissFirstConnectChecklist);
document.getElementById('first-connect-got-it')?.addEventListener('click', dismissFirstConnectChecklist);
document.querySelectorAll('.first-connect-link').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
dismissFirstConnectChecklist();
if (view) navigateToView(view);
});
});
// Activity tray
document.getElementById('activity-tray-toggle')?.addEventListener('click', (e) => {
e.stopPropagation();
const panel = document.getElementById('activity-panel');
if (!panel) return;
const open = panel.classList.toggle('hidden') === false;
document.getElementById('activity-tray-toggle')?.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) renderActivityPanel();
});
document.getElementById('activity-panel-close')?.addEventListener('click', () => {
document.getElementById('activity-panel')?.classList.add('hidden');
});
document.addEventListener('click', (e) => {
const tray = document.getElementById('activity-tray');
const panel = document.getElementById('activity-panel');
if (tray && panel && !tray.contains(e.target)) panel.classList.add('hidden');
});
const vaultForm = document.getElementById('vault-store-form');
if (vaultForm) {
vaultForm.addEventListener('submit', async (e) => {
@@ -1075,11 +1210,17 @@ function loadDashboard() {
if (networksEl) networksEl.textContent = String(snap.counts.networks ?? 0);
const dockerInfoEl = document.getElementById('docker-info-content');
if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) {
dockerInfoEl.innerHTML = `
<div><strong>${snap.engine.name || 'host'}</strong></div>
<div class="text-muted small">${snap.engine.operatingSystem || ''} · ${snap.engine.architecture || ''}</div>
<div class="text-muted small">API ${snap.engine.version?.ApiVersion || snap.engine.version?.apiVersion || '—'} · Swarm ${snap.engine.swarm || 'inactive'}</div>
<div class="text-muted small">${snap.engine.ncpu ?? '—'} CPU · volumes ${snap.counts.volumes ?? 0}</div>`;
const eng = snap.engine;
const m = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
dockerInfoEl.innerHTML = [
m('Host', eng.name || 'host'),
m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`),
m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'),
m('Swarm', eng.swarm || 'inactive'),
m('CPUs', eng.ncpu ?? '—'),
m('Volumes', snap.counts?.volumes ?? 0),
].join('');
dockerInfoEl.dataset.filled = '1';
}
})
@@ -1102,6 +1243,8 @@ function loadDashboard() {
sendCommand('listVolumes');
}
window.loadDashboard = loadDashboard;
/** @type {Array<object>} */
const dockerEventBuffer = [];
const MAX_EVENT_ROWS = 80;
@@ -1133,14 +1276,25 @@ function renderSystemDf(data) {
const imgSize = images.reduce((s, i) => s + (i.Size || 0), 0);
const volSize = volumes.reduce((s, v) => s + (v.UsageData?.Size || v.Size || 0), 0);
const cacheSize = buildCache.reduce((s, c) => s + (c.Size || 0), 0);
const contSize = containers.reduce((s, c) => s + (c.SizeRw || 0), 0);
const layerSize = layers ?? imgSize;
const total = Math.max(layerSize + volSize + cacheSize + contSize, 1);
el.innerHTML = `
<div class="key-value"><span>Image layers</span><strong>${formatBytes(layers ?? imgSize)}</strong></div>
<div class="key-value"><span>Images (${images.length})</span><strong>${formatBytes(imgSize)}</strong></div>
<div class="key-value"><span>Containers (${containers.length})</span><strong>${formatBytes(containers.reduce((s, c) => s + (c.SizeRw || 0), 0))}</strong></div>
<div class="key-value"><span>Volumes (${volumes.length})</span><strong>${formatBytes(volSize)}</strong></div>
<div class="key-value"><span>Build cache</span><strong>${formatBytes(cacheSize)}</strong></div>
`;
const row = (label, bytes) => {
const pct = Math.min(100, Math.round((Number(bytes || 0) / total) * 100));
return `<div class="dash-disk-row">
<div class="dash-disk-top"><span>${label}</span><strong>${formatBytes(bytes)}</strong></div>
<div class="dash-disk-bar"><i style="width:${pct}%"></i></div>
</div>`;
};
el.innerHTML = [
row('Image layers', layerSize),
row(`Images (${images.length})`, imgSize),
row(`Containers (${containers.length})`, contSize),
row(`Volumes (${volumes.length})`, volSize),
row('Build cache', cacheSize),
].join('');
}
function appendDockerEvent(event) {
@@ -1160,7 +1314,7 @@ function renderEventTimeline() {
const el = document.getElementById('docker-events-timeline');
if (!el) return;
if (dockerEventBuffer.length === 0) {
el.innerHTML = '<div class="text-muted small">Docker events will appear here…</div>';
el.innerHTML = '<div class="dash-empty">Docker events will stream here…</div>';
return;
}
el.innerHTML = dockerEventBuffer
@@ -1174,7 +1328,7 @@ function renderEventTimeline() {
ev.from ||
(ev.id ? String(ev.id).slice(0, 12) : '') ||
'';
return `<div class="event-row"><span>${time}</span><span class="event-type">${type}</span><span class="event-action">${action} ${name}</span></div>`;
return `<div class="event-row"><span>${time}</span><span class="event-type">${escapeHtmlLite(type)}</span><span class="event-action">${escapeHtmlLite(action)} ${escapeHtmlLite(name)}</span></div>`;
})
.join('');
}
@@ -1331,70 +1485,37 @@ function updateSystemInfo(systemInfo) {
if (dockerInfoEl && systemInfo.info) {
const info = systemInfo.info;
dockerInfoEl.innerHTML = `
<div class="key-value">
<span class="key">Docker Version:</span>
<span class="value">${systemInfo.version?.Version || 'Unknown'}</span>
</div>
<div class="key-value">
<span class="key">Containers:</span>
<span class="value">${info.Containers || 0}</span>
</div>
<div class="key-value">
<span class="key">Running:</span>
<span class="value">${info.ContainersRunning || 0}</span>
</div>
<div class="key-value">
<span class="key">Paused:</span>
<span class="value">${info.ContainersPaused || 0}</span>
</div>
<div class="key-value">
<span class="key">Stopped:</span>
<span class="value">${info.ContainersStopped || 0}</span>
</div>
<div class="key-value">
<span class="key">Images:</span>
<span class="value">${info.Images || 0}</span>
</div>
<div class="key-value">
<span class="key">Storage Driver:</span>
<span class="value">${info.Driver || 'Unknown'}</span>
</div>
`;
const metric = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
dockerInfoEl.innerHTML = [
metric('Version', systemInfo.version?.Version || 'Unknown'),
metric('Containers', info.Containers || 0),
metric('Running', info.ContainersRunning || 0),
metric('Paused', info.ContainersPaused || 0),
metric('Stopped', info.ContainersStopped || 0),
metric('Images', info.Images || 0),
metric('Storage driver', info.Driver || 'Unknown'),
].join('');
}
if (resourcesEl && systemInfo.info) {
const info = systemInfo.info;
const formatBytes = (bytes) => {
const fmt = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
resourcesEl.innerHTML = `
<div class="key-value">
<span class="key">Total Memory:</span>
<span class="value">${formatBytes(info.MemTotal)}</span>
</div>
<div class="key-value">
<span class="key">CPU Cores:</span>
<span class="value">${info.NCPU || 'Unknown'}</span>
</div>
<div class="key-value">
<span class="key">Operating System:</span>
<span class="value">${info.OperatingSystem || 'Unknown'}</span>
</div>
<div class="key-value">
<span class="key">Architecture:</span>
<span class="value">${info.Architecture || 'Unknown'}</span>
</div>
<div class="key-value">
<span class="key">Kernel Version:</span>
<span class="value">${info.KernelVersion || 'Unknown'}</span>
</div>
`;
const metric = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
resourcesEl.innerHTML = [
metric('Memory', fmt(info.MemTotal)),
metric('CPU cores', info.NCPU || 'Unknown'),
metric('Operating system', info.OperatingSystem || 'Unknown'),
metric('Architecture', info.Architecture || 'Unknown'),
metric('Kernel', info.KernelVersion || 'Unknown'),
].join('');
}
}
@@ -1749,42 +1870,62 @@ function renderNetworks(networks) {
}
// Volumes Functions
/** @type {Array<object>} */
let allStacksCache = [];
function loadStacks() {
if (!window.activePeer && !hasActiveConnection()) {
console.warn('[WARN] No active peer connection');
return;
}
showListSkeleton('stacks-list', 3);
showListSkeleton('stacks-list-body', 3);
sendCommand('listStacks');
}
function renderStacks(stacks) {
const stacksListBody = document.getElementById('stacks-list-body');
if (!stacksListBody) return;
allStacksCache = Array.isArray(stacks) ? stacks : [];
const q = document.getElementById('stack-search')?.value?.trim().toLowerCase() || '';
const filtered = !q
? allStacksCache
: allStacksCache.filter((s) => {
const name = String(s.name || '').toLowerCase();
const services = (s.services || []).join(' ').toLowerCase();
return name.includes(q) || services.includes(q);
});
if (!stacks || stacks.length === 0) {
stacksListBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No stacks found. Deploy a stack to get started.</td></tr>';
if (!filtered.length) {
stacksListBody.innerHTML = emptyTableRow(
5,
allStacksCache.length ? 'No matching stacks' : 'No stacks yet',
allStacksCache.length
? 'Clear search to see all stacks.'
: 'Deploy a compose stack to get started.'
);
return;
}
stacksListBody.innerHTML = stacks.map(stack => {
const runningCount = stack.containers.filter(c => c.state === 'running').length;
const totalCount = stack.containers.length;
stacksListBody.innerHTML = filtered.map(stack => {
const containers = stack.containers || [];
const runningCount = containers.filter(c => c.state === 'running').length;
const totalCount = containers.length;
const statusClass = runningCount === totalCount && totalCount > 0 ? 'text-success' :
runningCount > 0 ? 'text-warning' : 'text-danger';
const services = Array.isArray(stack.services) ? stack.services.join(', ') : '—';
return `
<tr>
<td><strong>${stack.name}</strong></td>
<td>${stack.services.join(', ')}</td>
<td><strong>${escapeHtmlLite(stack.name)}</strong></td>
<td>${escapeHtmlLite(services)}</td>
<td>${totalCount} container(s)</td>
<td><span class="${statusClass}">${runningCount}/${totalCount} running</span></td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info action-inspect-stack" data-stack-name="${stack.name}" title="Inspect">
<button class="btn btn-outline-info action-inspect-stack" data-stack-name="${escapeHtmlLite(stack.name)}" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-danger action-remove-stack" data-stack-name="${stack.name}" title="Remove Stack">
<button class="btn btn-outline-danger action-remove-stack" data-stack-name="${escapeHtmlLite(stack.name)}" title="Remove Stack" data-min-role="operator">
<i class="fas fa-trash"></i>
</button>
</div>
@@ -1792,6 +1933,7 @@ function renderStacks(stacks) {
</tr>
`;
}).join('');
applyRoleUI();
// Add event listeners
stacksListBody.querySelectorAll('.action-inspect-stack').forEach(btn => {
@@ -2103,49 +2245,15 @@ function pullImage() {
});
}
/** Legacy entry — always open smart network modal */
function createNetwork() {
const name = document.getElementById('network-name')?.value?.trim();
if (!name) {
showAlert('danger', 'Please enter a network name');
if (window.peardockOps?.openSmartNetworkModal) {
window.peardockOps.openSmartNetworkModal();
return;
}
const driver = document.getElementById('network-driver')?.value || 'bridge';
const subnet = document.getElementById('network-subnet')?.value?.trim() || null;
const modal = bootstrap.Modal.getInstance(document.getElementById('createNetworkModal'));
if (modal) modal.hide();
showStatusIndicator(`Creating network "${name}"...`);
sendCommand('createNetwork', { name, driver, subnet });
// Wait for response
showAlert('info', 'Use Create network from the Networks view');
// Keep a no-op timeout cleanup path for any old callers
const originalHandler = window.handlePeerResponse;
window.handlePeerResponse = (response) => {
if (response.success && response.message && response.message.includes('created successfully')) {
hideStatusIndicator();
showAlert('success', response.message);
loadNetworks();
if (currentView === 'dashboard') {
sendCommand('listNetworks');
}
// Reset form
document.getElementById('create-network-form')?.reset();
} else if (response.error) {
hideStatusIndicator();
// Error is already handled by centralized handler in handleRpcMessage
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
showAlert('danger', errorMsg);
}
}
if (typeof originalHandler === 'function') {
originalHandler(response);
}
window.handlePeerResponse = originalHandler;
};
setTimeout(() => {
if (window.handlePeerResponse === originalHandler) {
window.handlePeerResponse = originalHandler;
@@ -5378,9 +5486,11 @@ async function addConnection(publicKeyHex, meta = {}) {
startStatsInterval();
warmSnapshot();
hideWelcomePage();
if (!meta.quiet) hideStatusIndicator();
applyRoleUI();
if (!meta.quiet) {
hideStatusIndicator();
showAlert('success', `Connected to ${peerDisplayName(connections[topicId], topicId)}`);
showFirstConnectChecklist();
}
} catch (err) {
console.error('[ERROR] Connection failed', err);
@@ -5543,6 +5653,10 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('volume-search')?.addEventListener('input', () => {
if (typeof allVolumesCache !== 'undefined') renderVolumes(allVolumesCache);
});
document.getElementById('stack-search')?.addEventListener('input', () => {
if (typeof allStacksCache !== 'undefined') renderStacks(allStacksCache);
});
applyRoleUI();
// Set up sidebar collapse functionality
if (collapseSidebarBtn) {
@@ -6137,9 +6251,6 @@ function renderContainers(containers, topicId) {
<td>
<div class="d-flex align-items-center gap-2">
<span class="container-name-display" data-container-id="${containerId}" style="cursor: pointer; user-select: none;">${name}</span>
<button class="btn btn-outline-info action-rename p-1" title="Rename" style="font-size: 0.75rem;">
<i class="fas fa-edit"></i>
</button>
<a href="#" class="container-name-link d-none" data-container-id="${containerId}">${name}</a>
</div>
</td>
@@ -6162,41 +6273,39 @@ function renderContainers(containers, topicId) {
</div>
</td>
<td class="ip-address">${ipAddress}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-success action-start p-1" title="Start" ${container.State === 'running' ? 'disabled' : ''}>
<i class="fas fa-play"></i>
</button>
<button class="btn btn-outline-info action-restart p-1" title="Restart" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-redo"></i>
</button>
<button class="btn btn-outline-warning action-stop p-1" title="Stop" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-stop"></i>
</button>
<button class="btn btn-outline-danger action-kill p-1" title="Kill" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-skull"></i>
</button>
<button class="btn btn-outline-secondary action-pause p-1" title="Pause" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-pause"></i>
</button>
<button class="btn btn-outline-secondary action-top p-1" title="Processes" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-microchip"></i>
</button>
<button class="btn btn-outline-primary action-logs p-1" title="Logs">
<i class="fas fa-list-alt"></i>
</button>
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-terminal"></i>
</button>
<button class="btn btn-outline-info action-inspect p-1" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-secondary action-duplicate p-1" title="Duplicate">
<i class="fas fa-clone"></i>
</button>
<button class="btn btn-outline-danger action-remove p-1" title="Remove">
<i class="fas fa-trash"></i>
</button>
<td class="container-actions-cell">
<div class="container-actions">
<div class="btn-group btn-group-sm container-actions-primary">
<button class="btn btn-outline-success action-start p-1" title="Start" ${container.State === 'running' ? 'disabled' : ''}>
<i class="fas fa-play"></i>
</button>
<button class="btn btn-outline-warning action-stop p-1" title="Stop" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-stop"></i>
</button>
<button class="btn btn-outline-primary action-logs p-1" title="Logs">
<i class="fas fa-list-alt"></i>
</button>
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-terminal"></i>
</button>
</div>
<div class="dropdown container-actions-more">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle p-1" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="More actions">
<i class="fas fa-ellipsis"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><button type="button" class="dropdown-item action-restart" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-redo me-2"></i>Restart</button></li>
<li><button type="button" class="dropdown-item action-kill" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-skull me-2"></i>Kill</button></li>
<li><button type="button" class="dropdown-item action-pause" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-pause me-2"></i>Pause</button></li>
<li><button type="button" class="dropdown-item action-top" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-microchip me-2"></i>Processes</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item action-inspect"><i class="fas fa-info-circle me-2"></i>Inspect</button></li>
<li><button type="button" class="dropdown-item action-duplicate"><i class="fas fa-clone me-2"></i>Duplicate</button></li>
<li><button type="button" class="dropdown-item action-rename"><i class="fas fa-edit me-2"></i>Rename</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item text-danger action-remove"><i class="fas fa-trash me-2"></i>Remove</button></li>
</ul>
</div>
</div>
</td>
`;