Complete Track C roadmap: dashboard, job follow, actions, activity, roles
CI / test (push) Successful in 9m55s
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:
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user