Track E: recreate, system prune, Hub search, swarm secrets/configs
CI / test (push) Successful in 10m3s
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:
+60
-85
@@ -12,7 +12,8 @@
|
|||||||
| **B — Product UI & operator experience (Phases 7–12)** | ✅ Complete |
|
| **B — Product UI & operator experience (Phases 7–12)** | ✅ Complete |
|
||||||
| **C — UX polish & full-app experience (Phases 13–16)** | ✅ Complete |
|
| **C — UX polish & full-app experience (Phases 13–16)** | ✅ 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 A–D — ✅ Complete
|
||||||
|
|
||||||
- [x] HyperDHT + protomux-rpc, handshake, reconnect, schema validation
|
See git history for Phases 0–16, 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 B — Product UI & operator experience ✅
|
# Track E — Docker ops / Portainer-parity gaps
|
||||||
|
|
||||||
## Phase 7–12 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 C — UX polish & full-app experience ✅
|
# Track F — Optional 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 A–D 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.
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const AUDIT_METHODS = new Set([
|
|||||||
'pruneNetworks',
|
'pruneNetworks',
|
||||||
'pruneVolumes',
|
'pruneVolumes',
|
||||||
'pruneBuilder',
|
'pruneBuilder',
|
||||||
|
'systemPrune',
|
||||||
|
'recreateContainer',
|
||||||
'removeImage',
|
'removeImage',
|
||||||
'removeStack',
|
'removeStack',
|
||||||
'deployStack',
|
'deployStack',
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user