Track E: recreate, system prune, Hub search, swarm secrets/configs
CI / test (push) Successful in 10m3s

Investigate Docker/Portainer gaps and ship high-value ops: recreate
container, system prune + builder prune UI, Docker Hub search in
pull modal, Swarm secrets/configs tables. Update roadmap Track E/F.
This commit is contained in:
2026-07-10 23:48:30 -04:00
parent 869ba6a932
commit b62bcd697e
10 changed files with 490 additions and 92 deletions
+60 -85
View File
@@ -12,7 +12,8 @@
| **B — Product UI & operator experience (Phases 712)** | ✅ Complete | | **B — Product UI & operator experience (Phases 712)** | ✅ Complete |
| **C — UX polish & full-app experience (Phases 1316)** | ✅ Complete | | **C — UX polish & full-app experience (Phases 1316)** | ✅ Complete |
| **D — Holesail tunnels** | ✅ Complete | | **D — Holesail tunnels** | ✅ Complete |
| **Optional future items** | ✅ Complete | | **E — Docker ops / Portainer-parity gaps** | ✅ Complete (this pass) |
| **F — Optional future** | Open |
```bash ```bash
npm test # full suite green npm test # full suite green
@@ -31,94 +32,72 @@ npm run dev
| Peer disk cache | `client/peerCache.js``~/.config/peardock/cache/peers.json` | | Peer disk cache | `client/peerCache.js``~/.config/peardock/cache/peers.json` |
| Error model / quiet RPC noise | `client/errors.js` | | Error model / quiet RPC noise | `client/errors.js` |
| Job stepper + live log follow | `client/jobs.js`, `#job-drawer` | | Job stepper + live log follow | `client/jobs.js`, `#job-drawer` |
| Ops shell | `ui/ops-app.js`, `ui/components.js`, `ui/ops.css` | | Ops shell + settings subtabs | `ui/ops-app.js`, `ui/components.js`, `ui/ops.css` |
| Feedback | `libs/uiUtils.js` (job tray primary; toast opt-in) | | Feedback | `libs/uiUtils.js` (job tray primary; toast opt-in) |
| Holesail tunnels | `server/services/holesail-tunnels.js`, `docs/HOLESAIL.md` | | Holesail tunnels | `server/services/holesail-tunnels.js`, `docs/HOLESAIL.md` |
| GitOps stack sync | `server/utils/gitops.js`, `syncStackFromGit` | | GitOps stack sync | `server/utils/gitops.js`, `syncStackFromGit` |
| Virtualized container list | `renderContainers` windowing in `app.js` | | Virtualized container list | `renderContainers` windowing in `app.js` |
| Recreate container / system prune | `recreateContainer`, `systemPrune` handlers + dashboard/menu |
| Hub search | Pull-image modal + `searchImages` |
| Swarm secrets/configs | Swarm view tabs |
--- ---
# Track A — Platform foundation ✅ # Track AD✅ Complete
- [x] HyperDHT + protomux-rpc, handshake, reconnect, schema validation See git history for Phases 016, Holesail, GitOps, Swarm screens, settings subtabs, Bare holesail control.
- [x] Containers / images / networks / volumes / stacks / logs / terminals
- [x] Binary streams, metrics, stats history, engine create validation, deploy rollback
- [x] Roles, audit, browse deny, vault, peer invite/revoke
- [x] Swarm & plugins (feature-flagged), systemd, soak/load/fuzz tests
--- ---
# Track BProduct UI & operator experience ✅ # Track EDocker ops / Portainer-parity gaps
## Phase 712 summary ✅ Investigation (2026-07-11) compared Engine RPC surface vs UI and common Portainer operator flows.
- [x] App shell, palette, offline banner, design primitives ## Already strong (no work this pass)
- [x] Smart defaults (snapshot, IPAM, image suggest)
- [x] Dashboard / Host / Events / Fleet - [x] Containers lifecycle (start/stop/restart/kill/pause/rename/remove/commit/export)
- [x] Containers, images, networks, volumes, stacks, deploy jobs - [x] Duplicate container, bulk ops, logs, terminal, stats, top
- [x] Access / Settings / hash routes / multi-peer - [x] Images pull/build/tag/remove/history/prune/push/save/load
- [x] Multi template list URLs with cross-list dedupe - [x] Networks create/connect/disconnect/prune; volumes CRUD/prune
- [x] Stacks deploy + GitOps sync; Holesail tunnels
- [x] Roles, vault, invites, host snapshot, events, df
## Gaps found → implemented this pass ✅
| Gap (Portainer-like) | Delivery |
|----------------------|----------|
| **Recreate container** (same config, new instance) | RPC `recreateContainer` + ⋯ menu action |
| **System prune** (clean unused engine data) | RPC `systemPrune` + dashboard “System prune” |
| **Prune builder cache** in UI | Dashboard + Images toolbar → `pruneBuilder` |
| **Docker Hub search** before pull | Pull modal Hub search → fills image name |
| **Swarm secrets / configs list** | Swarm view tabs (list/inspect-ready) |
## Track E checklist ✅
- [x] `recreateContainer` (admin): stop → remove → create from inspect → optional start
- [x] `systemPrune` (admin): Engine `/system/prune` (+ volumes opt)
- [x] UI: Recreate on container overflow menu
- [x] UI: System prune + prune builder quick actions
- [x] UI: Hub search in pull-image modal
- [x] UI: Swarm Secrets + Configs tables
- [x] Protocol + audit + client API helpers
--- ---
# Track CUX polish & full-app experience ✅ # Track FOptional future (not blocking)
## Phase 13 — Trustworthy feedback ✅ These remain valid follow-ups; RPC may exist without full UI.
- [x] Floating toast stack + tray history (toasts opt-in; job tray primary) - [ ] **Volume file browser** (browse/archive in volume mounts) — partial `archiveContainerGet/Put` exists for containers only
- [x] `presentError` single path, unwrap REQUEST_ERROR causes - [ ] **Stack editor env file** + update-in-place stack
- [x] Quiet/dedupe background RPC noise - [ ] **Service scale / update UI** (RPC `scaleService` / `updateService` exist)
- [x] Destructive confirm respects Settings - [ ] **Create secret / config forms** (RPC create/remove exist; list UI done)
- [x] Density + auto-refresh prefs actually apply - [ ] **Disconnect network** from container networking tab (RPC `disconnectNetwork` exists)
- [ ] **Registry browser** beyond vault login
## Phase 14 — Lists ✅ - [ ] **Scheduled jobs / webhooks** (out of core Engine scope)
- [ ] **Container resource live editor** (CPU/memory via `updateContainer` — RPC exists)
- [x] Empty / skeleton states for containers, images, networks, volumes, stacks - [ ] **Log download / export** as file from UI
- [x] Search on images, networks, volumes, stacks - [ ] **Multi-host environments** as first-class (beyond Fleet peers)
- [x] Container primary actions + ⋯ overflow menu
## Phase 15 — Navigation, keyboard & a11y ✅
- [x] Browser history (`pushState` / `popstate` / `hashchange`)
- [x] Palette arrow keys + Escape
- [x] Confirm Escape cancels
- [x] `:focus-visible`, reduced motion
- [x] Notification tray does not mark all read on open
## Phase 16 — Experience polish ✅
- [x] Activity / recent jobs panel (titlebar list → reopen job drawer)
- [x] Role-aware hide/disable via `data-min-role` + `applyRoleUI()`
- [x] Invite form (role, TTL, max uses) + copy-token modal
- [x] Legacy create-network path redirects to smart modal
- [x] First-connect “what next” checklist
- [x] Job tray live log follows output
- [x] Modern dashboard redesign
- [x] Swarm screens when `ENABLE_SWARM=1` (`#swarm-view` services/nodes/tasks)
- [x] Virtualized tables for 500+ rows (container list windowing >80 rows)
---
# Track D — Holesail tunnels ✅
- [x] Design: Holesail as L4 data plane beside protomux control plane (`docs/HOLESAIL.md`)
- [x] Server tunnel manager (`server/services/holesail-tunnels.js`) + RPC handlers
- [x] Feature flag `ENABLE_HOLESAIL=1`, host allowlist, max tunnels, audit create/close
- [x] Client **Tunnels** view + API helpers
- [x] Container-details “Tunnel this port” one-click
- [x] Pear-side HolesailClient local bind + open browser (`client/holesailLocal.js`)
- [x] Persist/recreate tunnels across server restart (`peardock-tunnels.json`)
---
## Former “optional future” items ✅
- [x] Multi-step container deploy wizard chrome (step strip on deploy form)
- [x] Visual structure regression suite (`test/visual-structure.test.js`)
- [x] GitOps stack sync (`syncStackFromGit` + UI in deploy-stack modal)
- [x] Virtualized 500+ row tables (containers)
- [x] Swarm-specific screens when `ENABLE_SWARM=1`
--- ---
@@ -130,28 +109,24 @@ npm run dev
| Long operations | Job stepper + following live log | | Long operations | Job stepper + following live log |
| Permissions | MethodRoles + UI role gating | | Permissions | MethodRoles + UI role gating |
| Auto-population | Snapshot + IPAM + image suggest | | Auto-population | Snapshot + IPAM + image suggest |
| Dynamism | Warm cache, live events, dashboard, auto-refresh | | Feedback | Job tray + notification history (badge policy) |
| Offline | Banner + reconnect |
| Multi-peer | Fleet + active chip + disk peer cache |
| Feedback | Job tray + notification history |
| Templates | Multi-list merge + dedupe | | Templates | Multi-list merge + dedupe |
| Port sharing | Holesail tunnels + local client | | Port sharing | Holesail tunnels + Bare local control |
| GitOps | Shallow clone + compose deploy | | GitOps | Shallow clone + compose deploy |
| Engine hygiene | Prune * + system prune + recreate |
--- ---
## Definition of done ## Definition of done (updated)
1. ✅ Wizard/RPC paths for containers, images, networks, volumes, stacks 1. ✅ Wizard/RPC paths for containers, images, networks, volumes, stacks
2. ✅ Long jobs show steps + following log 2. ✅ Long jobs show steps + following log
3. ✅ Settings / Access / Host without `.env` for common client prefs 3. ✅ Settings subtabs for client prefs
4. ✅ Viewer/operator/admin model on server + Access UI + role-gated controls 4. ✅ Viewer/operator/admin model + Access UI
5.Empty/loading/offline/error states for core flows 5.Holesail + GitOps + Swarm lists
6.Create network auto-subnet; multi template lists 6.Recreate / system prune / Hub search / secrets+configs list
7.Peers persist across restarts (`peers.json`) 7.Automated tests green (`npm test`)
8. ✅ Automated tests green (`npm test`)
9. ✅ Optional roadmap items completed
--- ---
**Roadmap closed.** Tracks AD and previously optional items are implemented. **Track E complete.** Track F items are optional enhancements, not required for production ops console parity on core Engine workflows.
+172 -1
View File
@@ -1418,6 +1418,7 @@ async function pruneResource(kind) {
images: { method: 'pruneImages', label: 'unused images' }, images: { method: 'pruneImages', label: 'unused images' },
networks: { method: 'pruneNetworks', label: 'unused networks' }, networks: { method: 'pruneNetworks', label: 'unused networks' },
volumes: { method: 'pruneVolumes', label: 'unused volumes' }, volumes: { method: 'pruneVolumes', label: 'unused volumes' },
builder: { method: 'pruneBuilder', label: 'build cache' },
}; };
const conf = map[kind]; const conf = map[kind];
if (!conf) return; if (!conf) return;
@@ -1451,7 +1452,7 @@ async function pruneResource(kind) {
if (response?.success) { if (response?.success) {
showAlert('success', response.message || `Pruned ${conf.label}`); showAlert('success', response.message || `Pruned ${conf.label}`);
if (kind === 'containers') sendCommand('listContainers'); if (kind === 'containers') sendCommand('listContainers');
if (kind === 'images') sendCommand('listImages'); if (kind === 'images' || kind === 'builder') sendCommand('listImages');
if (kind === 'networks') sendCommand('listNetworks'); if (kind === 'networks') sendCommand('listNetworks');
if (kind === 'volumes') sendCommand('listVolumes'); if (kind === 'volumes') sendCommand('listVolumes');
sendCommand('getSystemDf'); sendCommand('getSystemDf');
@@ -1463,6 +1464,163 @@ async function pruneResource(kind) {
} }
window.pruneResource = pruneResource; window.pruneResource = pruneResource;
/**
* Docker system prune (unused containers, networks, images; optional volumes).
* @param {{ volumes?: boolean }} [opts]
*/
async function systemPrune(opts = {}) {
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
const volumes = Boolean(opts.volumes);
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(
volumes
? 'System prune including unused volumes? This cannot be undone.'
: 'System prune (stopped containers, unused networks & images)? Volumes are kept unless you opt in.',
() => {
confirmed = true;
resolve();
}
);
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator('Running docker system prune…');
try {
const response = await sendCommand('systemPrune', { volumes, all: false });
hideStatusIndicator();
if (response?.success) {
showAlert('success', response.message || 'System prune complete');
sendCommand('listContainers');
sendCommand('listImages');
sendCommand('listNetworks');
sendCommand('getSystemDf');
}
} catch (err) {
hideStatusIndicator();
showAlert('danger', err.message || 'System prune failed');
}
}
window.systemPrune = systemPrune;
/**
* Search Docker Hub via peardock searchImages RPC (pull modal).
*/
async function searchDockerHub() {
const term = document.getElementById('hub-search-term')?.value?.trim();
const host = document.getElementById('hub-search-results');
if (!host) return;
if (!term) {
showAlert('warning', 'Enter a Hub search term');
return;
}
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
host.innerHTML = '<div class="list-group-item text-muted">Searching Hub…</div>';
try {
const res = await sendCommand('searchImages', { term, limit: 15 });
const rows = res?.data || res?.results || [];
if (!rows.length) {
host.innerHTML = '<div class="list-group-item text-muted">No results</div>';
return;
}
host.innerHTML = rows
.map((r) => {
const name = r.name || r.Name || '';
const desc = (r.description || r.Description || '').slice(0, 120);
const stars = r.star_count ?? r.starCount ?? r.Stars ?? '—';
const official = r.is_official || r.isOfficial || r.Official ? ' · official' : '';
return `<button type="button" class="list-group-item list-group-item-action list-group-item-dark hub-result" data-name="${escapeHtmlLite(name)}">
<div class="d-flex justify-content-between gap-2">
<strong class="font-monospace">${escapeHtmlLite(name)}</strong>
<span class="text-muted"> ${escapeHtmlLite(String(stars))}${official}</span>
</div>
<div class="text-muted small text-truncate">${escapeHtmlLite(desc)}</div>
</button>`;
})
.join('');
host.querySelectorAll('.hub-result').forEach((btn) => {
btn.addEventListener('click', () => {
const n = btn.getAttribute('data-name') || '';
const input = document.getElementById('pull-image-name');
if (input && n) {
input.value = n.includes(':') ? n : `${n}:latest`;
input.focus();
}
});
});
} catch (err) {
host.innerHTML = `<div class="list-group-item text-danger">${escapeHtmlLite(err.message || 'Search failed')}</div>`;
}
}
window.searchDockerHub = searchDockerHub;
/**
* Recreate container with same config (admin).
* @param {{ Id: string, Names?: string[] }} container
*/
async function recreateContainerAction(container) {
if (!container?.Id) return;
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12);
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(
`Recreate container "${name}"? It will be stopped, removed, and created again with the same configuration.`,
() => {
confirmed = true;
resolve();
}
);
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator(`Recreating ${name}`);
try {
const response = await sendCommand('recreateContainer', {
id: container.Id,
start: true,
});
hideStatusIndicator();
if (response?.success) {
showAlert('success', response.message || `Recreated ${name}`);
sendCommand('listContainers');
}
} catch (err) {
hideStatusIndicator();
showAlert('danger', err.message || 'Recreate failed');
}
}
window.recreateContainerAction = recreateContainerAction;
function loadContainerTop(containerId) { function loadContainerTop(containerId) {
if (!containerId || !hasActiveConnection()) return; if (!containerId || !hasActiveConnection()) return;
const el = document.getElementById('container-top-content'); const el = document.getElementById('container-top-content');
@@ -5189,6 +5347,14 @@ function navigateToNewContainer(containerName) {
// Expose to window // Expose to window
window.pullImage = pullImage; window.pullImage = pullImage;
// Hub search Enter key in pull modal
document.getElementById('hub-search-term')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchDockerHub();
}
});
window.createNetwork = createNetwork; window.createNetwork = createNetwork;
window.createVolume = createVolume; window.createVolume = createVolume;
window.showContainerDetails = showContainerDetails; window.showContainerDetails = showContainerDetails;
@@ -6499,6 +6665,7 @@ function buildContainerRow(container) {
<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-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-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><button type="button" class="dropdown-item action-rename"><i class="fas fa-edit me-2"></i>Rename</button></li>
<li><button type="button" class="dropdown-item action-recreate" data-min-role="admin"><i class="fas fa-rotate me-2"></i>Recreate</button></li>
<li><hr class="dropdown-divider"></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> <li><button type="button" class="dropdown-item text-danger action-remove"><i class="fas fa-trash me-2"></i>Remove</button></li>
</ul> </ul>
@@ -6511,6 +6678,10 @@ function buildContainerRow(container) {
if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar()); if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
const duplicateBtn = row.querySelector('.action-duplicate'); const duplicateBtn = row.querySelector('.action-duplicate');
if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container)); if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
const recreateBtn = row.querySelector('.action-recreate');
if (recreateBtn) {
recreateBtn.addEventListener('click', () => recreateContainerAction(container));
}
const nameLink = row.querySelector('.container-name-link'); const nameLink = row.querySelector('.container-name-link');
if (nameLink) { if (nameLink) {
nameLink.addEventListener('click', (e) => { nameLink.addEventListener('click', (e) => {
+8
View File
@@ -209,6 +209,14 @@ export const api = {
pruneBuilder(connection) { pruneBuilder(connection) {
return connOrActive(connection).request(Methods.pruneBuilder, {}) return connOrActive(connection).request(Methods.pruneBuilder, {})
}
systemPrune(opts = {}, connection) {
return connOrActive(connection).request(Methods.systemPrune, opts)
}
recreateContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.recreateContainer, { id, ...opts })
}, },
// —— Networks / volumes —— // —— Networks / volumes ——
+42 -5
View File
@@ -399,6 +399,12 @@
<button type="button" class="dash-action dash-action--warn" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('images')" title="Prune unused images"> <button type="button" class="dash-action dash-action--warn" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('images')" title="Prune unused images">
<i class="fas fa-broom"></i><span>Prune images</span> <i class="fas fa-broom"></i><span>Prune images</span>
</button> </button>
<button type="button" class="dash-action dash-action--warn" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('builder')" title="Prune build cache">
<i class="fas fa-hammer"></i><span>Prune builder</span>
</button>
<button type="button" class="dash-action dash-action--danger" data-min-role="admin" onclick="window.systemPrune && window.systemPrune()" title="Docker system prune (unused data)">
<i class="fas fa-trash-can"></i><span>System prune</span>
</button>
</div> </div>
</footer> </footer>
</div> </div>
@@ -1004,6 +1010,9 @@
<button class="btn btn-outline-warning" type="button" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('images')"> <button class="btn btn-outline-warning" type="button" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('images')">
<i class="fas fa-broom me-2"></i>Prune <i class="fas fa-broom me-2"></i>Prune
</button> </button>
<button class="btn btn-outline-secondary" type="button" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('builder')" title="Prune build cache">
<i class="fas fa-hammer me-2"></i>Builder cache
</button>
</div> </div>
</div> </div>
@@ -1023,7 +1032,7 @@
<span id="filter-count-unused" class="badge bg-warning ms-2">0</span> <span id="filter-count-unused" class="badge bg-warning ms-2">0</span>
</button> </button>
</div> </div>
<input type="search" id="image-search" class="form-control form-control-sm bg-dark text-white list-search-input" placeholder="Search images…" spellcheck="false" autocomplete="off"> <input type="search" id="image-search" class="form-control form-control-sm bg-dark text-white list-search-input" placeholder="Filter local images…" spellcheck="false" autocomplete="off">
</div> </div>
<!-- Bulk Actions Toolbar for Images --> <!-- Bulk Actions Toolbar for Images -->
@@ -1389,6 +1398,8 @@
<li class="nav-item"><button class="nav-link active" data-swarm-tab="services" type="button">Services</button></li> <li class="nav-item"><button class="nav-link active" data-swarm-tab="services" type="button">Services</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="nodes" type="button">Nodes</button></li> <li class="nav-item"><button class="nav-link" data-swarm-tab="nodes" type="button">Nodes</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="tasks" type="button">Tasks</button></li> <li class="nav-item"><button class="nav-link" data-swarm-tab="tasks" type="button">Tasks</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="secrets" type="button">Secrets</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="configs" type="button">Configs</button></li>
</ul> </ul>
<div id="swarm-panel-services" class="swarm-panel"> <div id="swarm-panel-services" class="swarm-panel">
<div class="table-responsive"> <div class="table-responsive">
@@ -1414,6 +1425,22 @@
</table> </table>
</div> </div>
</div> </div>
<div id="swarm-panel-secrets" class="swarm-panel hidden">
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Name</th><th>Created</th><th>ID</th></tr></thead>
<tbody id="swarm-secrets-body"><tr><td colspan="3" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
<div id="swarm-panel-configs" class="swarm-panel hidden">
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Name</th><th>Created</th><th>ID</th></tr></thead>
<tbody id="swarm-configs-body"><tr><td colspan="3" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
@@ -3580,7 +3607,7 @@ services:
<!-- Pull Image Modal --> <!-- Pull Image Modal -->
<div class="modal fade" id="pullImageModal" tabindex="-1" aria-labelledby="pullImageModalLabel" aria-hidden="true"> <div class="modal fade" id="pullImageModal" tabindex="-1" aria-labelledby="pullImageModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-lg">
<div class="modal-content bg-dark text-white"> <div class="modal-content bg-dark text-white">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="pullImageModalLabel"><i class="fas fa-download me-2"></i>Pull Image</h5> <h5 class="modal-title" id="pullImageModalLabel"><i class="fas fa-download me-2"></i>Pull Image</h5>
@@ -3589,10 +3616,20 @@ services:
<div class="modal-body"> <div class="modal-body">
<form id="pull-image-form"> <form id="pull-image-form">
<div class="mb-3"> <div class="mb-3">
<label for="pull-image-name" class="form-label">Image Name</label> <label for="pull-image-name" class="form-label">Image name</label>
<input type="text" id="pull-image-name" class="form-control bg-dark text-white" placeholder="nginx:latest" required> <input type="text" id="pull-image-name" class="form-control bg-dark text-white font-monospace" placeholder="nginx:latest" required autocomplete="off" spellcheck="false">
<small class="text-muted">Enter the full image name with tag (e.g., nginx:latest, ubuntu:20.04)</small> <small class="text-muted">Full image reference (e.g. nginx:latest, ghcr.io/org/app:1.0)</small>
</div> </div>
<div class="mb-2">
<label class="form-label" for="hub-search-term">Search Docker Hub</label>
<div class="input-group input-group-sm">
<input type="search" id="hub-search-term" class="form-control bg-dark text-white" placeholder="Search Hub (e.g. redis, postgres)…" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-info" id="hub-search-btn" onclick="window.searchDockerHub && window.searchDockerHub()">
<i class="fas fa-magnifying-glass me-1"></i>Search
</button>
</div>
</div>
<div id="hub-search-results" class="list-group list-group-flush small mb-0" style="max-height: 220px; overflow: auto;"></div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+2
View File
@@ -20,6 +20,8 @@ const AUDIT_METHODS = new Set([
'pruneNetworks', 'pruneNetworks',
'pruneVolumes', 'pruneVolumes',
'pruneBuilder', 'pruneBuilder',
'systemPrune',
'recreateContainer',
'removeImage', 'removeImage',
'removeStack', 'removeStack',
'deployStack', 'deployStack',
+123
View File
@@ -536,6 +536,14 @@ export function registerContainerHandlers(session) {
return duplicateContainer(args, session) return duplicateContainer(args, session)
}) })
/**
* Recreate container with the same config (Portainer-style).
* Stops/removes the old container, creates a new one with the same name, starts if it was running.
*/
session.respond('recreateContainer', async (args) => {
return recreateContainer(args, session)
})
session.respond('execContainer', async (args) => { session.respond('execContainer', async (args) => {
const container = docker.getContainer(args.id) const container = docker.getContainer(args.id)
const exec = await container.exec({ const exec = await container.exec({
@@ -633,6 +641,121 @@ async function duplicateContainer(args, session) {
return { success: true, message: `Container '${name}' duplicated and started successfully.` } return { success: true, message: `Container '${name}' duplicated and started successfully.` }
} }
/**
* Build dockerode create options from an inspect result.
* @param {object} inspect
* @param {string} name
*/
function createOptsFromInspect(inspect, name) {
const config = { ...(inspect.Config || {}) }
// Runtime-only / identity fields must not be passed to create
for (const key of [
'Hostname',
'Domainname',
'Image',
'AttachStdin',
'AttachStdout',
'AttachStderr',
'Tty',
'OpenStdin',
'StdinOnce',
]) {
// keep these intentionally from Config below
void key
}
const hostConfig = { ...(inspect.HostConfig || {}) }
// Drop empty/null host config noise that can break recreate
for (const [k, v] of Object.entries(hostConfig)) {
if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) {
delete hostConfig[k]
}
}
const networks = inspect.NetworkSettings?.Networks || {}
const endpoints = {}
for (const [netName, conf] of Object.entries(networks)) {
if (!conf || netName === 'host' || netName === 'none') continue
endpoints[netName] = {
Aliases: conf.Aliases || undefined,
IPAMConfig: conf.IPAMConfig || undefined,
Links: conf.Links || undefined,
NetworkID: conf.NetworkID || undefined,
}
}
return {
name,
Image: config.Image,
Env: config.Env,
Cmd: config.Cmd,
Entrypoint: config.Entrypoint,
Labels: config.Labels,
WorkingDir: config.WorkingDir,
User: config.User,
Hostname: config.Hostname,
Domainname: config.Domainname,
Tty: config.Tty,
OpenStdin: config.OpenStdin,
StdinOnce: config.StdinOnce,
ExposedPorts: config.ExposedPorts,
Volumes: config.Volumes,
StopSignal: config.StopSignal,
StopTimeout: config.StopTimeout,
Healthcheck: config.Healthcheck,
HostConfig: hostConfig,
NetworkingConfig:
Object.keys(endpoints).length > 0 ? { EndpointsConfig: endpoints } : undefined,
}
}
/**
* @param {{ id: string, start?: boolean, removeVolumes?: boolean, timeout?: number }} args
*/
async function recreateContainer(args, session) {
const id = args.id
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const container = docker.getContainer(id)
const inspect = await container.inspect()
const name = String(inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
const wasRunning = Boolean(inspect.State?.Running)
const shouldStart = args.start !== false && (args.start === true || wasRunning)
session._cleanupLogsForContainer?.(id)
try {
if (wasRunning) {
await container.stop({ t: Number(args.timeout) >= 0 ? Number(args.timeout) : 10 })
}
} catch {
// already stopped
}
await container.remove({
force: true,
v: Boolean(args.removeVolumes || args.v),
})
const createOpts = createOptsFromInspect(inspect, name)
const created = await docker.createContainer(createOpts)
if (shouldStart) {
await created.start()
}
await broadcastContainers()
return {
success: true,
message: shouldStart
? `Container '${name}' recreated and started`
: `Container '${name}' recreated`,
id: created.id,
name,
started: shouldStart,
}
}
export async function broadcastContainers() { export async function broadcastContainers() {
try { try {
const containers = await docker.listContainers({ all: true }) const containers = await docker.listContainers({ all: true })
+45
View File
@@ -58,6 +58,51 @@ export function registerSystemHandlers(session) {
return { type: 'systemDf', data, success: true } return { type: 'systemDf', data, success: true }
}) })
/**
* Docker system prune (Portainer-style "clean unused data").
* @param {{ volumes?: boolean, all?: boolean }} args
*/
session.respond('systemPrune', async (args = {}) => {
const volumes = Boolean(args.volumes)
const all = Boolean(args.all)
let data = null
if (typeof docker.pruneSystem === 'function') {
data = await docker.pruneSystem({ volumes, all })
} else {
// Fallback: dial Engine /system/prune directly
data = await new Promise((resolve, reject) => {
const qs = new URLSearchParams()
if (volumes) qs.set('volumes', '1')
if (all) qs.set('all', '1')
const path = `/system/prune${qs.toString() ? `?${qs}` : ''}`
docker.modem.dial(
{
path,
method: 'POST',
statusCodes: {
200: true,
500: 'server error',
},
},
(err, result) => (err ? reject(err) : resolve(result))
)
})
}
logger.info('systemPrune completed', {
volumes,
all,
peerId: session.id?.slice?.(0, 12),
})
return {
success: true,
type: 'systemPrune',
message: volumes
? 'System pruned (including unused volumes)'
: 'System pruned (containers, networks, images; volumes kept)',
data,
}
})
session.respond('getMetrics', async () => { session.respond('getMetrics', async () => {
return getMetricsSnapshot({ peerCount: peers.size }) return getMetricsSnapshot({ peerCount: peers.size })
}) })
+5
View File
@@ -39,6 +39,7 @@ export const MethodRoles = Object.freeze({
getSystemDf: Roles.viewer, getSystemDf: Roles.viewer,
getDockerEvents: Roles.viewer, getDockerEvents: Roles.viewer,
getMetrics: Roles.viewer, getMetrics: Roles.viewer,
// systemPrune is admin — see below
startLogs: Roles.viewer, startLogs: Roles.viewer,
logs: Roles.viewer, logs: Roles.viewer,
stopLogs: Roles.viewer, stopLogs: Roles.viewer,
@@ -126,6 +127,8 @@ export const MethodRoles = Object.freeze({
// admin // admin
removeContainer: Roles.admin, removeContainer: Roles.admin,
recreateContainer: Roles.admin,
systemPrune: Roles.admin,
commitContainer: Roles.admin, commitContainer: Roles.admin,
exportContainer: Roles.admin, exportContainer: Roles.admin,
archiveContainerGet: Roles.admin, archiveContainerGet: Roles.admin,
@@ -202,6 +205,7 @@ export const Methods = Object.freeze({
pauseContainer: 'pauseContainer', pauseContainer: 'pauseContainer',
unpauseContainer: 'unpauseContainer', unpauseContainer: 'unpauseContainer',
removeContainer: 'removeContainer', removeContainer: 'removeContainer',
recreateContainer: 'recreateContainer',
renameContainer: 'renameContainer', renameContainer: 'renameContainer',
commitContainer: 'commitContainer', commitContainer: 'commitContainer',
exportContainer: 'exportContainer', exportContainer: 'exportContainer',
@@ -268,6 +272,7 @@ export const Methods = Object.freeze({
// System / host // System / host
getSystemInfo: 'getSystemInfo', getSystemInfo: 'getSystemInfo',
getSystemDf: 'getSystemDf', getSystemDf: 'getSystemDf',
systemPrune: 'systemPrune',
getDockerEvents: 'getDockerEvents', getDockerEvents: 'getDockerEvents',
getMetrics: 'getMetrics', getMetrics: 'getMetrics',
browseDirectory: 'browseDirectory', browseDirectory: 'browseDirectory',
+4
View File
@@ -73,4 +73,8 @@ test('every Methods entry has MethodRoles mapping', (t) => {
test('viewer can search images (read-only)', (t) => { test('viewer can search images (read-only)', (t) => {
t.ok(roleAllows(Roles.viewer, Methods.searchImages)) t.ok(roleAllows(Roles.viewer, Methods.searchImages))
t.ok(roleAllows(Roles.admin, Methods.recreateContainer))
t.absent(roleAllows(Roles.operator, Methods.recreateContainer))
t.ok(roleAllows(Roles.admin, Methods.systemPrune))
t.absent(roleAllows(Roles.operator, Methods.systemPrune))
}) })
+29 -1
View File
@@ -816,14 +816,18 @@ export async function loadSwarmView(opts = {}) {
const id = inspect?.data?.ID || inspect?.data?.id || '—' const id = inspect?.data?.ID || inspect?.data?.id || '—'
banner.innerHTML = `Swarm active · ID <code>${escape(String(id).slice(0, 16))}</code>` banner.innerHTML = `Swarm active · ID <code>${escape(String(id).slice(0, 16))}</code>`
} }
const [services, nodes, tasks] = await Promise.all([ const [services, nodes, tasks, secrets, configs] = await Promise.all([
manager.request(Methods.listServices, {}).catch(() => ({ data: [] })), manager.request(Methods.listServices, {}).catch(() => ({ data: [] })),
manager.request(Methods.listNodes, {}).catch(() => ({ data: [] })), manager.request(Methods.listNodes, {}).catch(() => ({ data: [] })),
manager.request(Methods.listTasks, {}).catch(() => ({ data: [] })), manager.request(Methods.listTasks, {}).catch(() => ({ data: [] })),
manager.request(Methods.listSecrets, {}).catch(() => ({ data: [] })),
manager.request(Methods.listConfigs, {}).catch(() => ({ data: [] })),
]) ])
const svcBody = document.getElementById('swarm-services-body') const svcBody = document.getElementById('swarm-services-body')
const nodeBody = document.getElementById('swarm-nodes-body') const nodeBody = document.getElementById('swarm-nodes-body')
const taskBody = document.getElementById('swarm-tasks-body') const taskBody = document.getElementById('swarm-tasks-body')
const secBody = document.getElementById('swarm-secrets-body')
const cfgBody = document.getElementById('swarm-configs-body')
const svcs = services?.data || [] const svcs = services?.data || []
if (svcBody) { if (svcBody) {
svcBody.innerHTML = svcs.length svcBody.innerHTML = svcs.length
@@ -864,6 +868,30 @@ export async function loadSwarmView(opts = {}) {
.join('') .join('')
: '<tr><td colspan="5" class="text-muted">No tasks</td></tr>' : '<tr><td colspan="5" class="text-muted">No tasks</td></tr>'
} }
const secs = secrets?.data || []
if (secBody) {
secBody.innerHTML = secs.length
? secs
.map((s) => {
const name = s.Spec?.Name || '—'
const created = s.CreatedAt ? new Date(s.CreatedAt).toLocaleString() : '—'
return `<tr><td>${escape(name)}</td><td class="small">${escape(created)}</td><td class="small font-monospace">${escape(String(s.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="3" class="text-muted">No secrets</td></tr>'
}
const cfgs = configs?.data || []
if (cfgBody) {
cfgBody.innerHTML = cfgs.length
? cfgs
.map((c) => {
const name = c.Spec?.Name || '—'
const created = c.CreatedAt ? new Date(c.CreatedAt).toLocaleString() : '—'
return `<tr><td>${escape(name)}</td><td class="small">${escape(created)}</td><td class="small font-monospace">${escape(String(c.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="3" class="text-muted">No configs</td></tr>'
}
} catch (err) { } catch (err) {
const code = err?.code || '' const code = err?.code || ''
const msg = err?.message || String(err) const msg = err?.message || String(err)