Move Peers into Settings and add a full Registry browser tab.
Release rolling / release (push) Successful in 9m39s

Peers live under Settings; Images is local-only. Registry is a top-level view with vault credentials, Hub search, and Registry API V2 catalog/tags/manifest/delete over the wire.
This commit is contained in:
Raven Scott
2026-07-15 16:04:27 -04:00
parent ab99136cb6
commit 9775fe5305
13 changed files with 1699 additions and 155 deletions
+13 -7
View File
@@ -953,9 +953,14 @@ function initNavigation() {
function navigateToView(viewName, opts = {}) {
const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts;
// Peers list is always reachable (manage saved keys even when offline)
const allowOffline =
viewName === 'peers' || viewName === 'settings' || skipWelcomeGate;
// Settings (incl. Peers subtab) is always reachable offline
// Legacy hash #/peers redirects into Settings → Peers
if (viewName === 'peers') {
viewName = 'settings';
opts = { ...opts, settingsTab: opts.settingsTab || 'peers' };
}
const allowOffline = viewName === 'settings' || skipWelcomeGate;
// Without an active peer, keep the welcome card and block workspace views
if (!allowOffline && !hasActiveConnection()) {
@@ -1026,6 +1031,8 @@ function navigateToView(viewName, opts = {}) {
}
} else if (viewName === 'images') {
loadImages();
} else if (viewName === 'registry') {
window.loadRegistryView?.() || window.refreshRegistryPanel?.();
} else if (viewName === 'networks') {
loadNetworks();
} else if (viewName === 'volumes') {
@@ -1041,8 +1048,6 @@ function navigateToView(viewName, opts = {}) {
if (typeof applyRoleUI === 'function') applyRoleUI();
} else if (viewName === 'fleet') {
loadFleetView();
} else if (viewName === 'peers') {
loadPeersView();
} else if (viewName === 'access') {
loadAccessView();
} else if (viewName === 'events') {
@@ -1052,7 +1057,7 @@ function navigateToView(viewName, opts = {}) {
} else if (viewName === 'tunnels') {
window.peardockOps?.loadTunnelsView?.();
} else if (viewName === 'settings') {
window.peardockOps?.loadSettingsView?.();
window.peardockOps?.loadSettingsView?.(opts.settingsTab);
}
// Browser history / deep-links (skip when handling popstate)
@@ -1825,8 +1830,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.first-connect-link').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
const settingsTab = btn.dataset.settingsTab;
dismissFirstConnectChecklist();
if (view) navigateToView(view);
if (view) navigateToView(view, settingsTab ? { settingsTab } : {});
});
});
+5 -4
View File
@@ -79,16 +79,16 @@ Sidebar `data-view="…"` sections, including:
|------|---------|
| containers | Table + details, lifecycle actions; **Add container** header button |
| add-container | Blank create form (always-pull, ports, advanced); not in sidebar |
| images | Local images + Registries tab (vault, pull/push with credentials) |
| images | Images, pull/build |
| images | Local engine images (pull/push/build/prune) |
| registry | Vault, Hub search, remote catalog/tags/manifests/delete |
| networks / volumes | Resource management |
| stacks | Compose projects |
| deploy | Templates + container configuration form |
| swarm | Services, nodes, tasks, secrets, configs |
| tunnels | Holesail create/list/close |
| host | System info / df / maintenance |
| peers / access | Fleet peers, invites, vault |
| settings | Density, accent, version, license |
| access | Server peer ACL, invites, vault quick-store |
| settings | Prefs + **Peers** subtab (client saved hosts) |
### Controllers
@@ -99,6 +99,7 @@ Sidebar `data-view="…"` sections, including:
| `ui/track-f-extras.js` | Extra polish features |
| `libs/templateDeploy.js` | Deploy form / templates (large) |
| `libs/addContainer.js` | Add container blank create form |
| `libs/registryManager.js` | Registry view: vault, browser, pull/push helpers |
| `libs/terminal.js` etc. | xterm integration |
### Auto-refresh
+21 -4
View File
@@ -54,17 +54,34 @@ Deploy templates form remains under the Deploy tab for catalog-driven deploys.
## Images
**UI:** Images view with **Local images** and **Registries** tabs.
**UI:** Images view — local Docker images on the connected host (pull/push/build/prune).
| Capability | Notes |
|------------|--------|
| Local inventory | Filter used/unused, search, multi-tag display |
| Pull | Modal with vault credential select; Hub search; re-pull from row |
| Push | Modal: choose tag, optional re-tag to registry namespace, vault credential; progress push |
| Tag / inspect / remove / build / prune | Existing actions |
| Registry manager | Vault CRUD (encrypted), test auth, session login/logout, auto-match credential by image host |
| Tag / inspect / remove / build / prune | Local engine actions |
**RPC:** `pullImage` / `pushImage` accept `credentialId` (and optional retag via `repo`+`tag` on push). Vault: `listVaultCredentials`, `vaultStoreCredential`, `vaultDeleteCredential`, `vaultUseCredential`, `vaultTestCredential`, `vaultClearSession` / `registryLogout`, `getAuthStatus`.
**RPC:** `pullImage` / `pushImage` accept `credentialId` (and optional retag via `repo`+`tag` on push).
---
## Registry
**UI:** Top-level **Registry** view (credentials, Docker Hub search, remote browser).
| Capability | Notes |
|------------|--------|
| Vault | Encrypted credential CRUD, test auth, session login/logout, auto-match by image host |
| Hub search | Engine Hub search → pull or open tags in browser |
| Catalog browser | Registry HTTP API V2 `_catalog` (where supported); open repo by name when not |
| Tags | List remote tags, optional digest enrichment, filter |
| Manifests | Inspect remote manifest (digest, layers, multi-arch) |
| Remote delete | Delete tag(s) via digest DELETE (admin; requires registry support) |
| Pull from browser | Pull selected remote tag onto the host |
**RPC:** Vault: `listVaultCredentials`, `vaultStoreCredential`, `vaultDeleteCredential`, `vaultUseCredential`, `vaultTestCredential`, `vaultClearSession` / `registryLogout`, `getAuthStatus`. Browser: `registryCatalog`, `registryListTags`, `registryGetManifest`, `registryDeleteTag`, `registryDeleteTags`, `registryNormalizeEndpoint`.
---
+210 -88
View File
@@ -173,6 +173,12 @@
<span class="nav-label">Images</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="registry" title="Registry">
<i class="fas fa-warehouse"></i>
<span class="nav-label">Registry</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
<i class="fas fa-hard-drive"></i>
@@ -186,12 +192,6 @@
</a>
</li>
<li class="nav-group-label">Host & access</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="peers" title="Peers">
<i class="fas fa-network-wired"></i>
<span class="nav-label">Peers</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="host" title="Host">
<i class="fas fa-microchip"></i>
@@ -220,7 +220,7 @@
</nav>
</div>
<!-- Fixed footer: quick-add peer only (full list lives on Peers view) -->
<!-- Fixed footer: quick-add peer (full list under Settings → Peers) -->
<div class="sidebar-footer">
<hr class="sidebar-divider">
<div class="connections-panel connections-panel--compact">
@@ -287,50 +287,12 @@
<ol class="first-connect-steps">
<li><button type="button" class="first-connect-link" data-view="dashboard">Open the dashboard</button> for live host health</li>
<li><button type="button" class="first-connect-link" data-view="containers">Browse containers</button> or <button type="button" class="first-connect-link" data-view="deploy">deploy a template</button></li>
<li><button type="button" class="first-connect-link" data-view="peers">Peers</button> to manage saved hosts · <button type="button" class="first-connect-link" data-view="settings">Settings</button> for prefs</li>
<li><button type="button" class="first-connect-link" data-view="settings" data-settings-tab="peers">Peers</button> in Settings to manage saved hosts · <button type="button" class="first-connect-link" data-view="settings">Settings</button> for prefs</li>
<li>Press <kbd>Ctrl</kbd>+<kbd>K</kbd> anytime for the command palette</li>
</ol>
<button type="button" class="btn btn-primary btn-sm" id="first-connect-got-it">Got it</button>
</div>
</div>
<!-- Peers view — full peer list (moved out of sidebar) -->
<div id="peers-view" class="view hidden">
<div class="container-fluid">
<div class="page-header d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<h2><i class="fas fa-network-wired"></i>Peers</h2>
<p class="page-subtitle">Saved HyperDHT peers for this client — click a peer to make it active</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary" id="peers-view-add-btn">
<i class="fas fa-plus me-1"></i>Add peer
</button>
<button type="button" class="btn btn-outline-secondary" id="peers-view-refresh-btn" title="Refresh status">
<i class="fas fa-sync"></i>
</button>
</div>
</div>
<div class="settings-section">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<h3 class="mb-0 h6 text-white">Saved peers</h3>
<span id="peers-view-count" class="small text-muted">0 peers</span>
</div>
<ul id="connection-list" class="list-group peers-connection-list"></ul>
<div id="peers-view-empty" class="text-muted small py-3 d-none">
No peers saved yet. Use <strong>Add peer</strong> to paste a 64-character public key from <code>npm run server</code>.
</div>
</div>
<div class="settings-section">
<h3 class="h6">Tips</h3>
<ul class="small text-muted mb-0">
<li>The active peer is highlighted and drives dashboard, containers, and tunnels.</li>
<li>Use <strong>Fleet</strong> for multi-host health side-by-side; tag environments there.</li>
<li>Server-side allowlist / invites live under <strong>Access</strong> when connected as admin.</li>
</ul>
</div>
</div>
</div>
<!-- Dashboard View — modern fit-to-viewport shell -->
<div id="dashboard-view" class="view hidden">
<div class="dashboard-layout dash">
@@ -1397,13 +1359,13 @@
</div>
</div>
<!-- Images View -->
<!-- Images View — local engine images only (remote registries: Registry tab) -->
<div id="images-view" class="view hidden">
<div class="container-fluid">
<div class="page-header">
<div>
<h2><i class="fas fa-layer-group"></i>Images</h2>
<p class="page-subtitle">Local images, pull/push, and registry vault</p>
<p class="page-subtitle">Local Docker images on this host · remote catalogs live under <a href="#/registry" class="link-info" onclick="event.preventDefault(); navigateToView('registry')">Registry</a></p>
</div>
<div class="btn-group flex-wrap">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#pullImageModal" data-min-role="operator">
@@ -1424,19 +1386,6 @@
</div>
</div>
<ul class="nav nav-tabs mb-3" id="images-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" type="button" data-images-tab="local">
<i class="fas fa-hard-drive me-1"></i>Local images
</button>
</li>
<li class="nav-item">
<button class="nav-link" type="button" data-images-tab="registries">
<i class="fas fa-warehouse me-1"></i>Registries
</button>
</li>
</ul>
<div id="images-panel-local">
<!-- Image Filters -->
<div class="view-toolbar mb-3 d-flex flex-wrap gap-2 align-items-center justify-content-between">
@@ -1494,21 +1443,153 @@
</table>
</div>
</div>
</div>
</div>
<!-- Registry manager -->
<div id="images-panel-registries" class="hidden">
<div id="registry-session-status" class="alert alert-secondary small mb-3">Loading session…</div>
<!-- Registry View — vault, Hub search, remote catalog / tag browser -->
<div id="registry-view" class="view hidden">
<div class="container-fluid">
<div class="page-header d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<h2><i class="fas fa-warehouse"></i>Registry</h2>
<p class="page-subtitle">Credentials, Docker Hub search, and remote repository browser (tags, manifests, cleanup)</p>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<select id="registry-active-credential" class="form-select form-select-sm bg-dark text-white registry-cred-select" style="min-width: 14rem;" title="Credential for remote API calls">
<option value="">Public / session auto</option>
</select>
<button type="button" class="btn btn-sm btn-outline-secondary" id="registry-refresh-btn" title="Refresh session &amp; vault">
<i class="fas fa-sync"></i>
</button>
</div>
</div>
<div id="registry-session-status" class="alert alert-secondary small mb-3">Connect a peer to manage registries.</div>
<ul class="nav nav-pills flex-wrap gap-1 mb-3" id="registry-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" class="nav-link active" data-registry-tab="browser" role="tab">
<i class="fas fa-folder-tree me-1"></i>Browser
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-registry-tab="hub" role="tab">
<i class="fas fa-magnifying-glass me-1"></i>Hub search
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-registry-tab="credentials" role="tab">
<i class="fas fa-key me-1"></i>Credentials
</button>
</li>
</ul>
<!-- Browser: catalog + open repo + tags -->
<div id="registry-panel-browser" data-registry-panel="browser">
<div class="row g-3 mb-3">
<div class="col-lg-5">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="fas fa-server me-2"></i>Repositories</span>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info" id="registry-catalog-btn" title="List via V2 _catalog">
<i class="fas fa-list me-1"></i>Catalog
</button>
<button type="button" class="btn btn-outline-secondary" id="registry-catalog-more-btn" title="Load more" disabled>
More
</button>
</div>
</div>
<div class="card-body">
<div class="input-group input-group-sm mb-2">
<input type="text" id="registry-server-override" class="form-control bg-dark text-white" placeholder="Registry host (optional; uses credential)" spellcheck="false" autocomplete="off">
</div>
<div class="input-group input-group-sm mb-2">
<input type="text" id="registry-open-repo" class="form-control bg-dark text-white font-monospace" placeholder="Open repo (e.g. library/nginx or org/app)" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-primary" id="registry-open-repo-btn">Open</button>
</div>
<input type="search" id="registry-repo-filter" class="form-control form-control-sm bg-dark text-white mb-2" placeholder="Filter catalog…" spellcheck="false" autocomplete="off">
<div id="registry-catalog-status" class="small text-muted mb-2"></div>
<div id="registry-catalog-list" class="list-group list-group-flush small registry-scroll-list" style="max-height: 22rem; overflow: auto;"></div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>
<i class="fas fa-tags me-2"></i>Tags
<code id="registry-active-repo" class="ms-2 small text-info"></code>
</span>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info" id="registry-tags-refresh-btn" disabled title="Reload tags">
<i class="fas fa-sync"></i>
</button>
<button type="button" class="btn btn-outline-info" id="registry-tags-enrich-btn" disabled title="Fetch digests for visible tags">
Digests
</button>
<button type="button" class="btn btn-outline-danger" id="registry-tags-delete-selected-btn" data-min-role="admin" disabled title="Delete selected tags remotely">
<i class="fas fa-trash me-1"></i>Delete
</button>
</div>
</div>
<div class="card-body">
<div id="registry-tags-empty" class="text-muted small py-4 text-center">
Open a repository from the catalog or by name to list remote tags.
</div>
<div id="registry-tags-toolbar" class="d-none mb-2 d-flex flex-wrap gap-2 align-items-center justify-content-between">
<input type="search" id="registry-tag-filter" class="form-control form-control-sm bg-dark text-white" style="max-width: 16rem;" placeholder="Filter tags…" spellcheck="false" autocomplete="off">
<span id="registry-tag-count" class="small text-muted"></span>
</div>
<div class="table-responsive registry-scroll-list" style="max-height: 22rem; overflow: auto;">
<table class="table table-dark table-sm table-hover mb-0 d-none" id="registry-tags-table">
<thead>
<tr>
<th style="width: 2rem;"><input type="checkbox" id="registry-tags-select-all" title="Select all visible"></th>
<th>Tag</th>
<th>Digest</th>
<th>Size</th>
<th style="width: 9rem;">Actions</th>
</tr>
</thead>
<tbody id="registry-tags-body"></tbody>
</table>
</div>
<div id="registry-manifest-panel" class="mt-3 d-none">
<h6 class="text-muted small text-uppercase">Manifest</h6>
<pre id="registry-manifest-json" class="bg-black border border-secondary rounded p-2 small mb-0" style="max-height: 14rem; overflow: auto; white-space: pre-wrap;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hub search -->
<div id="registry-panel-hub" class="hidden" data-registry-panel="hub">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-magnifying-glass me-2"></i>Docker Hub search</div>
<div class="card-body">
<p class="small text-muted">Search public Hub images via the engine API, then pull or open tags in the browser.</p>
<div class="input-group mb-3" style="max-width: 36rem;">
<input type="search" id="registry-hub-term" class="form-control bg-dark text-white" placeholder="Search Hub…" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-info" id="registry-hub-search-btn">Search</button>
</div>
<div id="registry-hub-results" class="list-group list-group-flush small" style="max-height: 28rem; overflow: auto;"></div>
</div>
</div>
</div>
<!-- Credentials -->
<div id="registry-panel-credentials" class="hidden" data-registry-panel="credentials">
<div class="row g-4">
<div class="col-lg-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-key me-2"></i>Vault credentials</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="registry-refresh-btn" title="Refresh">
<i class="fas fa-sync"></i>
</button>
</div>
<div class="card-body">
<p class="small text-muted">Encrypted at rest on the server. Use a credential for private pull/push, or activate it for the session.</p>
<p class="small text-muted">Encrypted at rest on the server. Use for private pull/push and remote API calls.</p>
<div id="registry-cred-list" class="mb-3"></div>
<hr class="border-secondary">
<h6 class="text-muted">Add registry</h6>
@@ -1541,7 +1622,7 @@
</div>
</div>
<div class="col-lg-6">
<div class="card bg-dark border-secondary mb-4">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-sign-in-alt me-2"></i>Session login</div>
<div class="card-body">
<p class="small text-muted">Log in for this session only (does not persist unless you also store).</p>
@@ -1569,14 +1650,14 @@
</form>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-magnifying-glass me-2"></i>Docker Hub search</div>
<div class="card-body">
<div class="input-group input-group-sm mb-2">
<input type="search" id="registry-hub-term" class="form-control bg-dark text-white" placeholder="Search Hub…" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-info" id="registry-hub-search-btn">Search</button>
</div>
<div id="registry-hub-results" class="list-group list-group-flush small" style="max-height: 240px; overflow: auto;"></div>
<div class="card bg-dark border-secondary mt-3">
<div class="card-header"><i class="fas fa-circle-info me-2"></i>Remote cleanup notes</div>
<div class="card-body small text-muted">
<ul class="mb-0 ps-3">
<li>Deleting a tag resolves its manifest digest and issues <code>DELETE /v2/…/manifests/&lt;digest&gt;</code>.</li>
<li>Docker Hub and many SaaS registries disallow remote delete — use their UI or enable delete on self-hosted registries (Harbor, distribution, etc.).</li>
<li>After deletes, run the registrys garbage collection if required for disk reclaim.</li>
</ul>
</div>
</div>
</div>
@@ -2336,7 +2417,7 @@ services:
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-key me-2"></i>Registry vault</div>
<div class="card-body">
<p class="small text-muted mb-2">Full registry manager lives under <strong>Images → Registries</strong> (test, session login, Hub search). Quick store here:</p>
<p class="small text-muted mb-2">Full registry manager lives under <strong>Registry</strong> (browser, credentials, Hub search). Quick store here:</p>
<form id="vault-store-form" class="mb-3">
<div class="row g-2">
<div class="col-md-6">
@@ -2353,7 +2434,7 @@ services:
</div>
<div class="col-12">
<button class="btn btn-sm btn-primary" type="submit">Store encrypted</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="navigateToView('images'); window.switchImagesTab && window.switchImagesTab('registries')">Open registry manager</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="navigateToView('registry')">Open registry manager</button>
</div>
</div>
</form>
@@ -2564,6 +2645,9 @@ services:
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="templates" role="tab">Templates</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="peers" role="tab">Peers</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="connections" role="tab">Connections</button>
</li>
@@ -2755,11 +2839,58 @@ services:
</div>
</div>
<!-- Peers (client-local HyperDHT connections) -->
<div class="settings-panel hidden" id="settings-panel-peers" data-settings-panel="peers">
<div class="settings-section">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 class="mb-1">Saved peers</h3>
<p class="small text-muted mb-0">HyperDHT public keys for this client — click a peer to make it active.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" id="peers-view-add-btn">
<i class="fas fa-plus me-1"></i>Add peer
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="peers-view-refresh-btn" title="Refresh status">
<i class="fas fa-sync"></i>
</button>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<span id="peers-view-count" class="small text-muted">0 peers</span>
</div>
<ul id="connection-list" class="list-group peers-connection-list"></ul>
<div id="peers-view-empty" class="text-muted small py-3 d-none">
No peers saved yet. Use <strong>Add peer</strong> to paste a 64-character public key from <code>npm run server</code>.
</div>
</div>
<div class="settings-section">
<h3 class="h6">Tips</h3>
<ul class="small text-muted mb-0">
<li>The active peer is highlighted and drives dashboard, containers, and tunnels.</li>
<li>Use <strong>Fleet</strong> for multi-host health side-by-side.</li>
<li>Server-side allowlist / invites live under <strong>Access</strong> when connected as admin.</li>
</ul>
</div>
<div class="settings-section">
<h3>Reset</h3>
<p class="small text-muted mb-2">
Clear all configured peers from this client. You will need to re-add public keys to connect again.
</p>
<button type="button" id="settings-reset-peers-btn" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash-can me-1"></i>Reset all peers
</button>
</div>
</div>
<!-- Connections -->
<div class="settings-panel hidden" id="settings-panel-connections" data-settings-panel="connections">
<div class="settings-section">
<h3>Active peer</h3>
<div id="settings-peer-info" class="small text-muted">Not connected</div>
<p class="small text-muted mt-2 mb-0">
Manage the full peer list under <button type="button" class="btn btn-link btn-sm p-0 align-baseline" onclick="window.peardockOps?.showSettingsTab?.('peers')">Settings → Peers</button>.
</p>
</div>
<div class="settings-section">
<h3>Server features</h3>
@@ -2773,15 +2904,6 @@ services:
<label class="form-check-label" for="settings-first-connect">Show first-connect tip after adding a peer</label>
</div>
</div>
<div class="settings-section">
<h3>Saved peers</h3>
<p class="small text-muted mb-2">
Clear all configured peers from this client. You will need to re-add public keys to connect again.
</p>
<button type="button" id="settings-reset-peers-btn" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash-can me-1"></i>Reset all peers
</button>
</div>
</div>
<!-- Terminal -->
+552 -38
View File
@@ -1,6 +1,6 @@
/**
* Registry manager + image push/pull helpers for the Images view.
* Uses the encrypted registry vault and session auth for private registries.
* Registry manager — vault, Hub search, remote V2 catalog/tags/manifest/delete.
* Lives in the top-level Registry view (not under Images).
*/
import { manager, Methods } from '../client/manager.js'
import { presentError } from '../client/errors.js'
@@ -22,6 +22,21 @@ let cachedCredentials = []
/** @type {{ authenticated?: boolean, username?: string|null, serveraddress?: string|null, credentialId?: string|null, label?: string|null }|null} */
let cachedAuthStatus = null
/** Browser state */
const browser = {
/** @type {string[]} */
repositories: [],
catalogSupported: null,
catalogError: null,
nextLast: null,
activeRepo: null,
/** @type {string[]} */
tags: [],
/** @type {Map<string, { digest?: string|null, sizeBytes?: number|null, mediaType?: string|null, error?: string }>} */
tagMeta: new Map(),
serveraddress: '',
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
@@ -34,6 +49,35 @@ function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;')
}
function formatBytes(n) {
if (n == null || !Number.isFinite(Number(n))) return '—'
const v = Number(n)
if (v < 1024) return `${v} B`
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`
return `${(v / 1024 ** 3).toFixed(2)} GB`
}
/**
* Credential id / server for remote browser RPCs.
*/
function browserAuthArgs() {
const credSel = document.getElementById('registry-active-credential')
const credVal = credSel?.value || ''
const serverOverride = document.getElementById('registry-server-override')?.value?.trim()
const args = {}
const id = credentialIdFromSelect(credVal)
if (id) args.credentialId = id
if (serverOverride) args.serveraddress = serverOverride
else if (credVal && credVal !== '__session__') {
const c = cachedCredentials.find((x) => x.id === credVal)
if (c?.serveraddress) args.serveraddress = c.serveraddress
} else if (cachedAuthStatus?.serveraddress) {
args.serveraddress = cachedAuthStatus.serveraddress
}
return args
}
/**
* @returns {Promise<object[]>}
*/
@@ -118,7 +162,35 @@ export function credentialIdFromSelect(selectValue) {
}
/**
* Refresh registry manager panel UI.
* Switch Registry view subtab.
* @param {'browser'|'hub'|'credentials'} tab
*/
export function switchRegistryTab(tab) {
const name = tab || 'browser'
document.querySelectorAll('[data-registry-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-registry-tab') === name)
})
document.querySelectorAll('[data-registry-panel]').forEach((panel) => {
panel.classList.toggle('hidden', panel.getAttribute('data-registry-panel') !== name)
})
if (name === 'credentials' || name === 'browser') {
refreshRegistryPanel().catch(() => {})
}
}
/** @deprecated use switchRegistryTab — kept for any leftover callers */
export function switchImagesTab(tab) {
if (tab === 'registries') {
if (typeof window.navigateToView === 'function') window.navigateToView('registry')
else switchRegistryTab('credentials')
return
}
// local images are their own view now
if (typeof window.navigateToView === 'function') window.navigateToView('images')
}
/**
* Refresh registry manager panel UI (session, vault list, browser credential select).
*/
export async function refreshRegistryPanel() {
const listEl = document.getElementById('registry-cred-list')
@@ -143,7 +215,8 @@ export async function refreshRegistryPanel() {
if (cachedAuthStatus?.authenticated) {
const who = cachedAuthStatus.label || cachedAuthStatus.username || 'user'
const server = cachedAuthStatus.serveraddress || 'registry'
statusEl.className = 'alert alert-success small mb-3 d-flex flex-wrap justify-content-between align-items-center gap-2'
statusEl.className =
'alert alert-success small mb-3 d-flex flex-wrap justify-content-between align-items-center gap-2'
statusEl.innerHTML = `
<span><i class="fas fa-check-circle me-1"></i>Session auth: <strong>${escapeHtml(who)}</strong>
<span class="text-muted">@ ${escapeHtml(server)}</span></span>
@@ -162,14 +235,14 @@ export async function refreshRegistryPanel() {
} else {
statusEl.className = 'alert alert-secondary small mb-3'
statusEl.innerHTML =
'<i class="fas fa-info-circle me-1"></i>No active registry session. Use a vault credential or log in below for private pulls/pushes.'
'<i class="fas fa-info-circle me-1"></i>No active registry session. Use a vault credential or log in under Credentials for private pulls/pushes and remote delete.'
}
}
if (listEl) {
if (!cachedCredentials.length) {
listEl.innerHTML =
'<p class="text-muted small mb-0">No stored credentials. Add one to pull/push private images.</p>'
'<p class="text-muted small mb-0">No stored credentials. Add one to pull/push private images and browse protected catalogs.</p>'
} else {
listEl.innerHTML = cachedCredentials
.map(
@@ -188,6 +261,9 @@ export async function refreshRegistryPanel() {
<button type="button" class="btn btn-outline-info reg-test" data-id="${escapeAttr(c.id)}" data-min-role="operator" title="Test login">
<i class="fas fa-vial"></i><span class="d-none d-md-inline ms-1">Test</span>
</button>
<button type="button" class="btn btn-outline-primary reg-browse" data-id="${escapeAttr(c.id)}" title="Browse this registry">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="btn btn-outline-danger reg-del" data-id="${escapeAttr(c.id)}" data-min-role="admin" title="Delete">
<i class="fas fa-trash"></i>
</button>
@@ -220,6 +296,19 @@ export async function refreshRegistryPanel() {
}
})
})
listEl.querySelectorAll('.reg-browse').forEach((btn) => {
btn.addEventListener('click', () => {
const c = cachedCredentials.find((x) => x.id === btn.dataset.id)
const sel = document.getElementById('registry-active-credential')
if (sel) sel.value = btn.dataset.id
if (c?.serveraddress) {
const o = document.getElementById('registry-server-override')
if (o) o.value = c.serveraddress
}
switchRegistryTab('browser')
loadRegistryCatalog({ reset: true }).catch(() => {})
})
})
listEl.querySelectorAll('.reg-del').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
@@ -252,6 +341,372 @@ export async function refreshRegistryPanel() {
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
}
export async function loadRegistryView() {
switchRegistryTab(
document.querySelector('[data-registry-tab].active')?.getAttribute('data-registry-tab') ||
'browser'
)
await refreshRegistryPanel()
}
function renderCatalogList() {
const list = document.getElementById('registry-catalog-list')
const status = document.getElementById('registry-catalog-status')
const filter = (document.getElementById('registry-repo-filter')?.value || '').toLowerCase()
if (!list) return
let repos = browser.repositories
if (filter) repos = repos.filter((r) => r.toLowerCase().includes(filter))
if (status) {
if (browser.catalogError) {
status.textContent = browser.catalogError
status.className = 'small text-warning mb-2'
} else if (browser.catalogSupported === false) {
status.textContent =
'Catalog API unavailable for this registry. Open a repository by name (right-hand field).'
status.className = 'small text-muted mb-2'
} else if (browser.repositories.length) {
status.textContent = `${browser.repositories.length} repositor${browser.repositories.length === 1 ? 'y' : 'ies'}${filter ? ` · ${repos.length} shown` : ''}`
status.className = 'small text-muted mb-2'
} else {
status.textContent = 'No catalog loaded yet. Click Catalog or open a repo by name.'
status.className = 'small text-muted mb-2'
}
}
if (!repos.length) {
list.innerHTML =
'<div class="text-muted small p-2">No repositories to show.</div>'
return
}
list.innerHTML = repos
.map(
(r) =>
`<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary reg-repo-pick font-monospace ${
browser.activeRepo === r ? 'active' : ''
}" data-repo="${escapeAttr(r)}">${escapeHtml(r)}</button>`
)
.join('')
list.querySelectorAll('.reg-repo-pick').forEach((btn) => {
btn.addEventListener('click', () => {
openRepository(btn.dataset.repo)
})
})
}
export async function loadRegistryCatalog(opts = {}) {
const status = document.getElementById('registry-catalog-status')
const moreBtn = document.getElementById('registry-catalog-more-btn')
if (!manager.active?.connected) {
if (status) status.textContent = 'Not connected'
return
}
if (status) {
status.textContent = 'Loading catalog…'
status.className = 'small text-muted mb-2'
}
try {
const args = {
...browserAuthArgs(),
n: 100,
}
if (!opts.reset && browser.nextLast) args.last = browser.nextLast
const res = await manager.request(Methods.registryCatalog, args)
if (opts.reset) browser.repositories = []
const next = res?.repositories || []
for (const r of next) {
if (!browser.repositories.includes(r)) browser.repositories.push(r)
}
browser.catalogSupported = res?.supported !== false
browser.catalogError = res?.error || null
browser.nextLast = res?.nextLast || null
browser.serveraddress = res?.serveraddress || browser.serveraddress
if (moreBtn) moreBtn.disabled = !browser.nextLast
renderCatalogList()
} catch (err) {
browser.catalogError = err.message
browser.catalogSupported = false
if (status) {
status.textContent = err.message
status.className = 'small text-danger mb-2'
}
presentError(err, 'registryCatalog', { showAlert })
}
}
function renderTagsTable() {
const empty = document.getElementById('registry-tags-empty')
const table = document.getElementById('registry-tags-table')
const body = document.getElementById('registry-tags-body')
const toolbar = document.getElementById('registry-tags-toolbar')
const countEl = document.getElementById('registry-tag-count')
const repoLabel = document.getElementById('registry-active-repo')
const delBtn = document.getElementById('registry-tags-delete-selected-btn')
const refreshBtn = document.getElementById('registry-tags-refresh-btn')
const enrichBtn = document.getElementById('registry-tags-enrich-btn')
if (repoLabel) repoLabel.textContent = browser.activeRepo || ''
if (!browser.activeRepo) {
if (empty) empty.classList.remove('d-none')
if (table) table.classList.add('d-none')
if (toolbar) toolbar.classList.add('d-none')
if (delBtn) delBtn.disabled = true
if (refreshBtn) refreshBtn.disabled = true
if (enrichBtn) enrichBtn.disabled = true
return
}
if (empty) empty.classList.add('d-none')
if (toolbar) toolbar.classList.remove('d-none')
if (table) table.classList.remove('d-none')
if (refreshBtn) refreshBtn.disabled = false
if (enrichBtn) enrichBtn.disabled = !browser.tags.length
const filter = (document.getElementById('registry-tag-filter')?.value || '').toLowerCase()
let tags = browser.tags
if (filter) tags = tags.filter((t) => t.toLowerCase().includes(filter))
if (countEl) {
countEl.textContent = `${browser.tags.length} tag(s)${filter ? ` · ${tags.length} shown` : ''}`
}
if (!body) return
if (!tags.length) {
body.innerHTML = `<tr><td colspan="5" class="text-muted small">No tags match.</td></tr>`
if (delBtn) delBtn.disabled = true
return
}
body.innerHTML = tags
.map((tag) => {
const meta = browser.tagMeta.get(tag) || {}
const dig = meta.digest
? `<code class="small" title="${escapeAttr(meta.digest)}">${escapeHtml(meta.digest.slice(0, 19))}…</code>`
: meta.error
? `<span class="text-danger small" title="${escapeAttr(meta.error)}">error</span>`
: '<span class="text-muted">—</span>'
return `<tr data-tag="${escapeAttr(tag)}">
<td><input type="checkbox" class="reg-tag-check" value="${escapeAttr(tag)}"></td>
<td class="font-monospace">${escapeHtml(tag)}</td>
<td>${dig}</td>
<td class="small">${escapeHtml(formatBytes(meta.sizeBytes))}</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info reg-tag-manifest" data-tag="${escapeAttr(tag)}" title="Inspect manifest">
<i class="fas fa-file-code"></i>
</button>
<button type="button" class="btn btn-outline-success reg-tag-pull" data-tag="${escapeAttr(tag)}" data-min-role="operator" title="Pull to host">
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-danger reg-tag-del" data-tag="${escapeAttr(tag)}" data-min-role="admin" title="Delete remote tag">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>`
})
.join('')
body.querySelectorAll('.reg-tag-manifest').forEach((btn) => {
btn.addEventListener('click', () => inspectManifest(btn.dataset.tag))
})
body.querySelectorAll('.reg-tag-pull').forEach((btn) => {
btn.addEventListener('click', () => pullTag(btn.dataset.tag))
})
body.querySelectorAll('.reg-tag-del').forEach((btn) => {
btn.addEventListener('click', () => deleteTags([btn.dataset.tag]))
})
const updateDel = () => {
const n = body.querySelectorAll('.reg-tag-check:checked').length
if (delBtn) delBtn.disabled = n === 0
}
body.querySelectorAll('.reg-tag-check').forEach((cb) => {
cb.addEventListener('change', updateDel)
})
updateDel()
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
}
export async function openRepository(repo) {
const name = String(repo || '').trim()
if (!name) return
browser.activeRepo = name
browser.tags = []
browser.tagMeta = new Map()
document.getElementById('registry-manifest-panel')?.classList.add('d-none')
renderCatalogList()
renderTagsTable()
const empty = document.getElementById('registry-tags-empty')
if (empty) {
empty.classList.remove('d-none')
empty.textContent = `Loading tags for ${name}`
}
try {
showStatusIndicator(`Listing tags for ${name}`)
const res = await manager.request(Methods.registryListTags, {
repository: name,
...browserAuthArgs(),
})
browser.activeRepo = res.repository || name
browser.tags = res.tags || []
browser.serveraddress = res.serveraddress || browser.serveraddress
if (res.tagsDetail) {
for (const t of res.tagsDetail) {
browser.tagMeta.set(t.tag, t)
}
}
renderTagsTable()
showAlert('success', `${browser.tags.length} tag(s) in ${browser.activeRepo}`)
} catch (err) {
presentError(err, 'registryListTags', { showAlert })
if (empty) {
empty.classList.remove('d-none')
empty.textContent = err.message || 'Failed to list tags'
}
} finally {
hideStatusIndicator()
}
}
export async function enrichVisibleTags() {
if (!browser.activeRepo || !browser.tags.length) return
try {
showStatusIndicator('Fetching digests…')
const res = await manager.request(Methods.registryListTags, {
repository: browser.activeRepo,
enrich: true,
enrichLimit: 50,
...browserAuthArgs(),
})
if (res.tagsDetail) {
for (const t of res.tagsDetail) {
browser.tagMeta.set(t.tag, t)
}
}
renderTagsTable()
} catch (err) {
presentError(err, 'registryListTags', { showAlert })
} finally {
hideStatusIndicator()
}
}
async function inspectManifest(tag) {
if (!browser.activeRepo || !tag) return
const panel = document.getElementById('registry-manifest-panel')
const pre = document.getElementById('registry-manifest-json')
try {
showStatusIndicator(`Loading manifest ${tag}`)
const res = await manager.request(Methods.registryGetManifest, {
repository: browser.activeRepo,
reference: tag,
...browserAuthArgs(),
})
if (res.digest) {
const prev = browser.tagMeta.get(tag) || {}
browser.tagMeta.set(tag, {
...prev,
digest: res.digest,
sizeBytes: res.sizeBytes ?? prev.sizeBytes,
mediaType: res.mediaType || prev.mediaType,
})
renderTagsTable()
}
if (panel) panel.classList.remove('d-none')
if (pre) {
pre.textContent = JSON.stringify(
{
repository: res.repository,
reference: res.reference,
digest: res.digest,
mediaType: res.mediaType,
sizeBytes: res.sizeBytes,
architecture: res.architecture,
os: res.os,
platformCount: res.platformCount,
manifest: res.manifest,
},
null,
2
)
}
} catch (err) {
presentError(err, 'registryGetManifest', { showAlert })
} finally {
hideStatusIndicator()
}
}
async function pullTag(tag) {
if (!browser.activeRepo || !tag) return
const host = browser.serveraddress || browserAuthArgs().serveraddress || ''
let image = `${browser.activeRepo}:${tag}`
// Prefer fully qualified name for non-Hub when we know host
if (host && !host.includes('docker.io') && !host.includes('index.docker.io')) {
try {
const u = host.includes('://') ? new URL(host) : new URL(`https://${host}`)
const h = u.host
if (h && !browser.activeRepo.startsWith(h + '/')) {
image = `${h}/${browser.activeRepo}:${tag}`
}
} catch {
// keep short form
}
}
const credId = credentialIdFromSelect(
document.getElementById('registry-active-credential')?.value
)
await pullImageWithAuth({ image, credentialId: credId }).catch(() => {})
}
async function deleteTags(tags) {
if (!browser.activeRepo || !tags?.length) return
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive(
'Delete remote tags',
`Permanently delete ${tags.length} tag(s) from ${browser.activeRepo} on the registry? This cannot be undone. Some registries (Docker Hub) disallow remote delete.`
)
: typeof confirm === 'function'
? confirm(`Delete ${tags.length} remote tag(s)?`)
: true
if (!ok) return
try {
showStatusIndicator(`Deleting ${tags.length} tag(s)…`)
if (tags.length === 1) {
await manager.request(Methods.registryDeleteTag, {
repository: browser.activeRepo,
reference: tags[0],
...browserAuthArgs(),
})
showAlert('success', `Deleted ${tags[0]}`)
} else {
const res = await manager.request(Methods.registryDeleteTags, {
repository: browser.activeRepo,
tags,
...browserAuthArgs(),
})
showAlert(
'success',
`Deleted ${res.deleted || 0}, failed ${res.failed || 0}`
)
}
await openRepository(browser.activeRepo)
} catch (err) {
presentError(err, 'registryDeleteTag', { showAlert })
} finally {
hideStatusIndicator()
}
}
/**
* Pull image with optional vault credential.
* @param {{ image: string, credentialId?: string }} args
@@ -263,6 +718,7 @@ export async function pullImageWithAuth(args) {
if (args.credentialId) body.credentialId = args.credentialId
const host =
document.getElementById('registry-view') ||
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body
@@ -307,6 +763,7 @@ export async function pushImageWithAuth(args) {
const label = args.repo ? `${args.repo}:${args.tag || 'latest'}` : image
const host =
document.getElementById('registry-view') ||
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body
@@ -356,7 +813,6 @@ export async function openPushImageModal(opts = {}) {
const credSelect = document.getElementById('push-image-credential')
let tags = (opts.repoTags || []).filter((t) => t && t !== '<none>:<none>')
// Header "Push" with no image: offer all local tagged images
if (!opts.id && !tags.length && Array.isArray(window.allImages)) {
const options = []
for (const img of window.allImages) {
@@ -402,7 +858,9 @@ export async function openPushImageModal(opts = {}) {
}
if (repoInput) {
const def = opts.defaultRef || tags[0] || ''
const [r, t] = def.includes(':') ? [def.slice(0, def.lastIndexOf(':')), def.slice(def.lastIndexOf(':') + 1)] : [def, 'latest']
const [r, t] = def.includes(':')
? [def.slice(0, def.lastIndexOf(':')), def.slice(def.lastIndexOf(':') + 1)]
: [def, 'latest']
repoInput.value = r.startsWith('sha256') ? '' : r
if (tagInput) tagInput.value = t || 'latest'
}
@@ -410,8 +868,7 @@ export async function openPushImageModal(opts = {}) {
fillCredentialSelect(credSelect)
togglePushRetagFields()
const modal = bootstrap.Modal.getOrCreateInstance(modalEl)
modal.show()
bootstrap.Modal.getOrCreateInstance(modalEl).show()
}
function togglePushRetagFields() {
@@ -420,32 +877,67 @@ function togglePushRetagFields() {
if (wrap) wrap.style.display = retag ? '' : 'none'
}
/**
* Switch Images view subtab.
* @param {'local'|'registries'} tab
*/
export function switchImagesTab(tab) {
const local = document.getElementById('images-panel-local')
const registries = document.getElementById('images-panel-registries')
document.querySelectorAll('[data-images-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-images-tab') === tab)
})
if (local) local.classList.toggle('hidden', tab !== 'local')
if (registries) registries.classList.toggle('hidden', tab !== 'registries')
if (tab === 'registries') refreshRegistryPanel()
}
/**
* Wire DOM once.
*/
export function initRegistryManager() {
// Subtabs
document.querySelectorAll('[data-images-tab]').forEach((btn) => {
document.querySelectorAll('[data-registry-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
switchImagesTab(btn.getAttribute('data-images-tab') || 'local')
switchRegistryTab(btn.getAttribute('data-registry-tab') || 'browser')
})
})
document.getElementById('registry-refresh-btn')?.addEventListener('click', () => {
refreshRegistryPanel()
})
document.getElementById('registry-catalog-btn')?.addEventListener('click', () => {
loadRegistryCatalog({ reset: true })
})
document.getElementById('registry-catalog-more-btn')?.addEventListener('click', () => {
loadRegistryCatalog({ reset: false })
})
document.getElementById('registry-open-repo-btn')?.addEventListener('click', () => {
const name = document.getElementById('registry-open-repo')?.value?.trim()
if (name) openRepository(name)
})
document.getElementById('registry-open-repo')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
const name = e.target.value?.trim()
if (name) openRepository(name)
}
})
document.getElementById('registry-repo-filter')?.addEventListener('input', () => {
renderCatalogList()
})
document.getElementById('registry-tag-filter')?.addEventListener('input', () => {
renderTagsTable()
})
document.getElementById('registry-tags-refresh-btn')?.addEventListener('click', () => {
if (browser.activeRepo) openRepository(browser.activeRepo)
})
document.getElementById('registry-tags-enrich-btn')?.addEventListener('click', () => {
enrichVisibleTags()
})
document.getElementById('registry-tags-delete-selected-btn')?.addEventListener('click', () => {
const checks = [
...document.querySelectorAll('#registry-tags-body .reg-tag-check:checked'),
]
const tags = checks.map((c) => c.value).filter(Boolean)
deleteTags(tags)
})
document.getElementById('registry-tags-select-all')?.addEventListener('change', (e) => {
const on = e.target.checked
document.querySelectorAll('#registry-tags-body .reg-tag-check').forEach((cb) => {
cb.checked = on
})
const delBtn = document.getElementById('registry-tags-delete-selected-btn')
if (delBtn) {
delBtn.disabled = !on || !document.querySelectorAll('#registry-tags-body .reg-tag-check').length
}
})
// Store credential form
const storeForm = document.getElementById('registry-store-form')
if (storeForm && !storeForm.dataset.wired) {
@@ -476,7 +968,7 @@ export function initRegistryManager() {
})
}
// Session login (without vault store)
// Session login
const loginForm = document.getElementById('registry-login-form')
if (loginForm && !loginForm.dataset.wired) {
loginForm.dataset.wired = '1'
@@ -510,7 +1002,7 @@ export function initRegistryManager() {
})
}
// Hub search on registries tab
// Hub search
const hubBtn = document.getElementById('registry-hub-search-btn')
const hubTerm = document.getElementById('registry-hub-term')
if (hubBtn && !hubBtn.dataset.wired) {
@@ -532,14 +1024,26 @@ export function initRegistryManager() {
const name = r.name || r.Name || ''
const stars = r.star_count ?? r.starCount ?? 0
const desc = r.description || r.Description || ''
return `<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary hub-pick" data-name="${escapeAttr(name)}">
<div class="d-flex justify-content-between"><strong class="font-monospace">${escapeHtml(name)}</strong>
<span class="badge bg-secondary">${stars} ★</span></div>
<div class="small text-muted text-truncate">${escapeHtml(desc)}</div>
</button>`
return `<div class="list-group-item bg-dark text-white border-secondary">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="min-w-0">
<strong class="font-monospace">${escapeHtml(name)}</strong>
<div class="small text-muted text-truncate">${escapeHtml(desc)}</div>
</div>
<span class="badge bg-secondary flex-shrink-0">${stars} ★</span>
</div>
<div class="btn-group btn-group-sm mt-2">
<button type="button" class="btn btn-outline-success hub-pull" data-name="${escapeAttr(name)}">
<i class="fas fa-download me-1"></i>Pull
</button>
<button type="button" class="btn btn-outline-info hub-open" data-name="${escapeAttr(name)}">
<i class="fas fa-folder-open me-1"></i>Tags
</button>
</div>
</div>`
})
.join('')
out.querySelectorAll('.hub-pick').forEach((btn) => {
out.querySelectorAll('.hub-pull').forEach((btn) => {
btn.addEventListener('click', () => {
const name = btn.dataset.name
const pullName = document.getElementById('pull-image-name')
@@ -551,6 +1055,14 @@ export function initRegistryManager() {
}
})
})
out.querySelectorAll('.hub-open').forEach((btn) => {
btn.addEventListener('click', () => {
switchRegistryTab('browser')
const open = document.getElementById('registry-open-repo')
if (open) open.value = btn.dataset.name
openRepository(btn.dataset.name)
})
})
} catch (err) {
out.innerHTML = `<div class="text-danger small p-2">${escapeHtml(err.message)}</div>`
}
@@ -564,7 +1076,7 @@ export function initRegistryManager() {
})
}
// Pull modal enhancements
// Pull modal
const pullModal = document.getElementById('pullImageModal')
pullModal?.addEventListener('show.bs.modal', () => {
preparePullModal()
@@ -617,13 +1129,15 @@ export function initRegistryManager() {
})
}
// Expose for app.js action buttons
window.openPushImageModal = openPushImageModal
window.pullImageWithAuth = pullImageWithAuth
window.pushImageWithAuth = pushImageWithAuth
window.refreshRegistryPanel = refreshRegistryPanel
window.loadRegistryView = loadRegistryView
window.switchImagesTab = switchImagesTab
window.switchRegistryTab = switchRegistryTab
window.preparePullModal = preparePullModal
window.openRegistryRepository = openRepository
}
/**
+219
View File
@@ -0,0 +1,219 @@
/**
* Remote registry browser RPC — catalog, tags, manifests, delete.
*/
import * as validation from '../utils/validation.js'
import {
fetchCatalog,
fetchTags,
fetchManifest,
deleteManifest,
enrichTagsWithDigests,
normalizeRegistryEndpoint,
} from '../services/registry-client.js'
import { resolveRegistryAuth } from './vault.js'
/**
* Resolve auth + server address for browser ops.
* @param {import('../rpc/session.js').PeerSession} session
* @param {object} args
*/
function resolveBrowserAuth(session, args = {}) {
const auth = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault !== false,
image: args.repository
? `${args.serveraddress || 'docker.io'}/${args.repository}`
: args.image,
})
const serveraddress =
args.serveraddress ||
auth?.serveraddress ||
'https://index.docker.io/v1/'
return {
auth: auth
? { username: auth.username, password: auth.password }
: null,
serveraddress: validation.sanitizeString(serveraddress, 512),
}
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function registerRegistryHandlers(session) {
session.respond('registryCatalog', async (args = {}) => {
const { auth, serveraddress } = resolveBrowserAuth(session, args)
const result = await fetchCatalog(serveraddress, auth, {
n: args.n,
last: args.last,
})
return {
success: true,
type: 'registryCatalog',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
isDockerHub: result.endpoint.isDockerHub,
repositories: result.repositories,
supported: result.supported,
error: result.error,
nextLast: result.nextLast,
}
})
session.respond('registryListTags', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
if (!repository) throw new Error('repository required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
const result = await fetchTags(repository, serveraddress, auth)
let tagsDetail = null
if (args.enrich && result.tags.length) {
tagsDetail = await enrichTagsWithDigests(
result.repository,
result.tags,
serveraddress,
auth,
{ limit: args.enrichLimit || 40 }
)
}
return {
success: true,
type: 'registryTags',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
repository: result.repository,
tags: result.tags,
tagsDetail,
tagCount: result.tags.length,
}
})
session.respond('registryGetManifest', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const reference = validation.sanitizeString(
args.reference || args.tag || args.digest || '',
256
)
if (!repository || !reference) throw new Error('repository and reference required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
const result = await fetchManifest(repository, reference, serveraddress, auth)
// Don't ship full layer blobs — trim manifest for UI
let manifestSummary = result.manifest
if (manifestSummary && typeof manifestSummary === 'object') {
manifestSummary = {
schemaVersion: manifestSummary.schemaVersion,
mediaType: manifestSummary.mediaType,
architecture: manifestSummary.architecture,
os: manifestSummary.os,
config: manifestSummary.config
? { digest: manifestSummary.config.digest, size: manifestSummary.config.size, mediaType: manifestSummary.config.mediaType }
: undefined,
layers: Array.isArray(manifestSummary.layers)
? manifestSummary.layers.map((l) => ({
digest: l.digest,
size: l.size,
mediaType: l.mediaType,
}))
: undefined,
manifests: Array.isArray(manifestSummary.manifests)
? manifestSummary.manifests.map((m) => ({
digest: m.digest,
mediaType: m.mediaType,
platform: m.platform,
size: m.size,
}))
: undefined,
}
}
return {
success: true,
type: 'registryManifest',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
repository: result.repository,
reference: result.reference,
digest: result.digest,
mediaType: result.mediaType,
sizeBytes: result.sizeBytes,
platformCount: result.platformCount,
architecture: result.architecture,
os: result.os,
childDigests: result.childDigests,
manifest: manifestSummary,
}
})
session.respond('registryDeleteTag', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const reference = validation.sanitizeString(
args.reference || args.tag || args.digest || '',
256
)
if (!repository || !reference) throw new Error('repository and reference required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
if (!auth) {
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
code: 'AUTH_REQUIRED',
})
}
const result = await deleteManifest(repository, reference, serveraddress, auth)
return {
success: true,
type: 'registryDelete',
...result,
host: result.endpoint?.host,
serveraddress: result.endpoint?.serveraddress,
}
})
session.respond('registryDeleteTags', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const tags = Array.isArray(args.tags)
? args.tags.map((t) => String(t || '').trim()).filter(Boolean)
: []
if (!repository || !tags.length) throw new Error('repository and tags[] required')
if (tags.length > 50) throw new Error('Max 50 tags per bulk delete')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
if (!auth) {
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
code: 'AUTH_REQUIRED',
})
}
/** @type {Array<object>} */
const results = []
for (const tag of tags) {
try {
const r = await deleteManifest(repository, tag, serveraddress, auth)
results.push({ tag, ok: true, digest: r.digest, deleted: r.deleted })
} catch (err) {
results.push({ tag, ok: false, error: err.message })
}
}
const deleted = results.filter((r) => r.ok && r.deleted).length
return {
success: true,
type: 'registryBulkDelete',
repository,
deleted,
failed: results.filter((r) => !r.ok).length,
results,
}
})
session.respond('registryNormalizeEndpoint', async (args = {}) => {
const ep = normalizeRegistryEndpoint(args.serveraddress || args.server || '')
return { success: true, type: 'registryEndpoint', ...ep }
})
}
+2
View File
@@ -16,6 +16,7 @@ import { registerSwarmHandlers } from '../handlers/swarm.js'
import { registerPluginHandlers } from '../handlers/plugins.js'
import { registerPeerHandlers } from '../handlers/peers.js'
import { registerVaultHandlers } from '../handlers/vault.js'
import { registerRegistryHandlers } from '../handlers/registry.js'
import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js'
@@ -39,6 +40,7 @@ export function registerAllHandlers(session) {
registerPluginHandlers(session)
registerPeerHandlers(session)
registerVaultHandlers(session)
registerRegistryHandlers(session)
registerBinaryStreamHandlers(session)
registerSuggestionHandlers(session)
registerTunnelHandlers(session)
+572
View File
@@ -0,0 +1,572 @@
/**
* Docker Registry HTTP API V2 client (catalog, tags, manifests, delete).
*
* Works on Node and Bare (host + agent:false — see image-updates notes).
* Auth: Basic and Bearer (WWW-Authenticate), using vault/session credentials.
*/
import https from 'https'
import http from 'http'
const DEFAULT_TIMEOUT_MS = Math.min(
45_000,
Number(process.env.PEARDOCK_REGISTRY_TIMEOUT_MS) || 20_000
)
const MANIFEST_ACCEPT = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.v1+json',
].join(', ')
/**
* Normalize a registry server address from vault / Docker login form to
* { baseUrl, host, registryApiHost, isDockerHub }.
* @param {string} [serveraddress]
*/
export function normalizeRegistryEndpoint(serveraddress) {
let raw = String(serveraddress || 'https://index.docker.io/v1/').trim()
if (!raw) raw = 'https://index.docker.io/v1/'
// Bare host without scheme
if (!/^https?:\/\//i.test(raw)) {
raw = `https://${raw}`
}
let u
try {
u = new URL(raw)
} catch {
throw new Error(`Invalid registry URL: ${serveraddress}`)
}
let host = u.hostname
const port = u.port
const hostWithPort = port ? `${host}:${port}` : host
const isDockerHub =
host === 'docker.io' ||
host === 'index.docker.io' ||
host === 'registry-1.docker.io' ||
host === 'registry.hub.docker.com' ||
raw.includes('index.docker.io')
// Registry API host for Hub is registry-1.docker.io
const registryApiHost = isDockerHub
? 'registry-1.docker.io'
: hostWithPort
const scheme = u.protocol === 'http:' ? 'http:' : 'https:'
const baseUrl = isDockerHub
? 'https://registry-1.docker.io'
: `${scheme}//${registryApiHost}`
return {
baseUrl,
host: isDockerHub ? 'docker.io' : host,
registryApiHost,
isDockerHub,
serveraddress: isDockerHub ? 'https://index.docker.io/v1/' : `${scheme}//${registryApiHost}`,
}
}
/**
* @param {string} url
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number, body?: string|null }} opts
* @returns {Promise<{ status: number, headers: Record<string,string>, body: string }>}
*/
export function registryHttpRequest(url, opts = {}) {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
return new Promise((resolve, reject) => {
let u
try {
u = new URL(url)
} catch (err) {
reject(err)
return
}
const lib = u.protocol === 'http:' ? http : https
const port = u.port
? Number(u.port)
: u.protocol === 'http:'
? 80
: 443
const headers = {
Connection: 'close',
...(opts.headers || {}),
}
if (opts.body != null && headers['Content-Length'] == null) {
headers['Content-Length'] = String(Buffer.byteLength(opts.body))
}
const reqOpts = {
protocol: u.protocol,
host: u.hostname,
hostname: u.hostname,
servername: u.hostname,
port,
path: u.pathname + u.search,
method: opts.method || 'GET',
headers,
agent: false,
timeout: timeoutMs,
}
let settled = false
const finish = (err, val) => {
if (settled) return
settled = true
clearTimeout(timer)
if (err) reject(err)
else resolve(val)
}
const timer = setTimeout(() => {
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
finish(new Error('Registry request timed out'))
}, timeoutMs)
let req
try {
req = lib.request(reqOpts, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const headersOut = {}
for (const [k, v] of Object.entries(res.headers || {})) {
headersOut[String(k).toLowerCase()] = Array.isArray(v)
? v.join(', ')
: String(v ?? '')
}
finish(null, {
status: res.statusCode || 0,
headers: headersOut,
body: Buffer.concat(chunks).toString('utf8'),
})
})
res.on('error', (err) => finish(err))
})
} catch (err) {
finish(err)
return
}
req.on('timeout', () => {
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
})
req.on('error', (err) => finish(err))
try {
if (typeof req.setTimeout === 'function') req.setTimeout(timeoutMs)
} catch {
// ignore
}
if (opts.body != null) req.write(opts.body)
req.end()
})
}
/**
* @param {string} header
*/
function parseWwwAuthenticate(header) {
if (!header || !/bearer/i.test(header)) return null
const params = {}
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) {
params[m[1].toLowerCase()] = m[2]
}
if (!params.realm) return null
return params
}
/**
* @param {ReturnType<typeof normalizeRegistryEndpoint>} endpoint
* @param {{ username?: string, password?: string }|null} auth
* @param {string} [wwwAuthHeader]
* @param {string} [scope]
*/
async function getBearerToken(endpoint, auth, wwwAuthHeader, scope) {
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
let realm = params.realm
let service = params.service
let tokenScope = params.scope || scope
if (!realm && endpoint.isDockerHub) {
realm = 'https://auth.docker.io/token'
service = 'registry.docker.io'
}
if (!realm) return null
const u = new URL(realm)
if (service) u.searchParams.set('service', service)
if (tokenScope) u.searchParams.set('scope', tokenScope)
const headers = { Accept: 'application/json', Connection: 'close' }
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
const res = await registryHttpRequest(u.toString(), { headers })
if (res.status < 200 || res.status >= 300) {
throw new Error(`Token request failed (${res.status})`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid token response')
}
return json.token || json.access_token || null
}
/**
* @param {object} opts
* @param {ReturnType<typeof normalizeRegistryEndpoint>} opts.endpoint
* @param {string} opts.path — path under registry host, starts with /
* @param {string} [opts.method]
* @param {Record<string,string>} [opts.headers]
* @param {{ username?: string, password?: string }|null} [opts.auth]
* @param {string} [opts.scope] — bearer scope if known
* @param {string|null} [opts.body]
*/
export async function registryRequest(opts) {
const { endpoint, path: apiPath, method = 'GET', auth = null, scope, body = null } = opts
const url = new URL(
apiPath.startsWith('/') ? apiPath : `/${apiPath}`,
endpoint.baseUrl.endsWith('/') ? endpoint.baseUrl : endpoint.baseUrl + '/'
).toString()
/** @type {Record<string, string>} */
const headers = {
Accept: opts.headers?.Accept || 'application/json',
...(opts.headers || {}),
}
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
async function once(hdrs) {
return registryHttpRequest(url, { method, headers: hdrs, body })
}
let res = await once(headers)
if (res.status === 401 || res.status === 403) {
const token = await getBearerToken(endpoint, auth, res.headers['www-authenticate'], scope)
if (token) {
const next = { ...headers, Authorization: `Bearer ${token}` }
res = await once(next)
}
}
return res
}
/**
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ n?: number, last?: string }} [paging]
*/
export async function fetchCatalog(serveraddress, auth = null, paging = {}) {
const endpoint = normalizeRegistryEndpoint(serveraddress)
const n = Math.min(1000, Math.max(1, Number(paging.n) || 100))
let path = `/v2/_catalog?n=${n}`
if (paging.last) path += `&last=${encodeURIComponent(paging.last)}`
const res = await registryRequest({
endpoint,
path,
auth,
scope: 'registry:catalog:*',
headers: { Accept: 'application/json' },
})
if (res.status === 404 || res.status === 401 || res.status === 403) {
return {
endpoint,
repositories: [],
supported: false,
error:
res.status === 404
? 'This registry does not expose _catalog (common for Docker Hub / GHCR). Open a repository by name instead.'
: `Catalog not available (${res.status}). Check credentials or open a repository by name.`,
status: res.status,
}
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Catalog failed (${res.status}): ${res.body.slice(0, 200)}`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid catalog response')
}
const repositories = Array.isArray(json.repositories) ? json.repositories : []
// Link header pagination (RFC 5988) — extract last= if present
let nextLast = null
const link = res.headers.link || ''
const m = link.match(/[?&]last=([^&>]+)/)
if (m) nextLast = decodeURIComponent(m[1])
else if (repositories.length >= n) {
nextLast = repositories[repositories.length - 1]
}
return {
endpoint,
repositories,
supported: true,
error: null,
status: res.status,
nextLast: nextLast && repositories.length >= n ? nextLast : null,
}
}
/**
* @param {string} repository
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function fetchTags(repository, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
if (!repo) throw new Error('repository required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
// Docker Hub library images: nginx → library/nginx
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/tags/list`,
auth,
scope: `repository:${name}:pull`,
headers: { Accept: 'application/json' },
})
if (res.status === 404) {
throw Object.assign(new Error(`Repository not found: ${name}`), { code: 'REPO_NOT_FOUND' })
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`List tags failed (${res.status}): ${res.body.slice(0, 200)}`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid tags list response')
}
const tags = Array.isArray(json.tags) ? json.tags.filter(Boolean).sort() : []
return {
endpoint,
repository: name,
name: json.name || name,
tags,
}
}
/**
* @param {string} repository
* @param {string} reference — tag or digest
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function fetchManifest(repository, reference, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
const ref = String(reference || '').trim()
if (!repo || !ref) throw new Error('repository and reference required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(ref)}`,
auth,
scope: `repository:${name}:pull`,
headers: { Accept: MANIFEST_ACCEPT },
})
if (res.status === 404) {
throw Object.assign(new Error(`Manifest not found: ${name}:${ref}`), {
code: 'MANIFEST_NOT_FOUND',
})
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Get manifest failed (${res.status}): ${res.body.slice(0, 200)}`)
}
const digest =
res.headers['docker-content-digest'] || res.headers['oci-content-digest'] || null
let manifest = null
try {
manifest = JSON.parse(res.body)
} catch {
manifest = { raw: res.body.slice(0, 4000) }
}
/** @type {string[]} */
const childDigests = []
if (Array.isArray(manifest?.manifests)) {
for (const m of manifest.manifests) {
if (m?.digest) childDigests.push(String(m.digest))
}
}
// Approximate size from config + layers when single-arch
let sizeBytes = null
if (manifest?.config?.size) sizeBytes = Number(manifest.config.size) || 0
if (Array.isArray(manifest?.layers)) {
sizeBytes = (sizeBytes || 0) + manifest.layers.reduce((a, l) => a + (Number(l.size) || 0), 0)
}
return {
endpoint,
repository: name,
reference: ref,
digest,
mediaType: manifest?.mediaType || res.headers['content-type'] || null,
schemaVersion: manifest?.schemaVersion ?? null,
architecture: manifest?.architecture || null,
os: manifest?.os || null,
childDigests,
platformCount: Array.isArray(manifest?.manifests) ? manifest.manifests.length : null,
sizeBytes,
manifest,
}
}
/**
* Delete a tag or digest from the registry (requires delete permission).
* Tags are resolved to digest first (DELETE by tag is not in V2).
*
* @param {string} repository
* @param {string} reference — tag or sha256:…
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function deleteManifest(repository, reference, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
const ref = String(reference || '').trim()
if (!repo || !ref) throw new Error('repository and reference required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
let digest = ref.startsWith('sha256:') ? ref : null
if (!digest) {
const m = await fetchManifest(name, ref, serveraddress, auth)
digest = m.digest
if (!digest) throw new Error('Could not resolve tag to digest for delete')
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
method: 'DELETE',
auth,
scope: `repository:${name}:delete`,
headers: { Accept: MANIFEST_ACCEPT },
})
// Some registries want pull+delete scope; retry with combined if 401
if (res.status === 401 || res.status === 403) {
const retry = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
method: 'DELETE',
auth,
scope: `repository:${name}:*`,
headers: { Accept: MANIFEST_ACCEPT },
})
if (retry.status === 202 || retry.status === 200 || retry.status === 204) {
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: retry.status }
}
if (retry.status === 404) {
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
}
throw new Error(
`Delete failed (${retry.status}). Registry may disallow remote delete or credential lacks delete scope.`
)
}
if (res.status === 202 || res.status === 200 || res.status === 204) {
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: res.status }
}
if (res.status === 404) {
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
}
if (res.status === 405) {
throw new Error(
'Registry does not allow remote delete (HTTP 405). Enable delete on the registry or use its native GC tools.'
)
}
throw new Error(`Delete failed (${res.status}): ${res.body.slice(0, 200)}`)
}
/**
* Enrich tags with digests (best-effort, capped concurrency).
* @param {string} repository
* @param {string[]} tags
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ limit?: number }} [opts]
*/
export async function enrichTagsWithDigests(
repository,
tags,
serveraddress,
auth = null,
opts = {}
) {
const limit = Math.min(tags.length, Math.max(1, Number(opts.limit) || 40))
const slice = tags.slice(0, limit)
/** @type {Array<{ tag: string, digest: string|null, sizeBytes: number|null, mediaType: string|null, error?: string }>} */
const out = []
let i = 0
const workers = Math.min(4, slice.length)
async function worker() {
while (i < slice.length) {
const idx = i++
const tag = slice[idx]
try {
const m = await fetchManifest(repository, tag, serveraddress, auth)
out[idx] = {
tag,
digest: m.digest,
sizeBytes: m.sizeBytes,
mediaType: m.mediaType,
}
} catch (err) {
out[idx] = {
tag,
digest: null,
sizeBytes: null,
mediaType: null,
error: err.message,
}
}
}
}
await Promise.all(Array.from({ length: workers }, () => worker()))
return out.filter(Boolean)
}
+14
View File
@@ -55,6 +55,10 @@ export const MethodRoles = Object.freeze({
getAuthStatus: Roles.viewer,
listVaultCredentials: Roles.viewer,
vaultTestCredential: Roles.operator,
registryCatalog: Roles.viewer,
registryListTags: Roles.viewer,
registryGetManifest: Roles.viewer,
registryNormalizeEndpoint: Roles.viewer,
listPeers: Roles.viewer,
/** Invite strings are secrets — admin only (not viewer/operator read-only) */
listInvites: Roles.admin,
@@ -128,6 +132,8 @@ export const MethodRoles = Object.freeze({
vaultUseCredential: Roles.operator,
vaultClearSession: Roles.operator,
registryLogout: Roles.operator,
registryDeleteTag: Roles.admin,
registryDeleteTags: Roles.admin,
createService: Roles.operator,
updateService: Roles.operator,
scaleService: Roles.operator,
@@ -308,6 +314,14 @@ export const Methods = Object.freeze({
vaultTestCredential: 'vaultTestCredential',
vaultClearSession: 'vaultClearSession',
// Remote registry browser (Registry HTTP API V2)
registryCatalog: 'registryCatalog',
registryListTags: 'registryListTags',
registryGetManifest: 'registryGetManifest',
registryDeleteTag: 'registryDeleteTag',
registryDeleteTags: 'registryDeleteTags',
registryNormalizeEndpoint: 'registryNormalizeEndpoint',
// Peer ACL
listPeers: 'listPeers',
invitePeer: 'invitePeer',
+36
View File
@@ -0,0 +1,36 @@
import test from 'brittle'
import { normalizeRegistryEndpoint } from '../server/services/registry-client.js'
test('normalizeRegistryEndpoint Docker Hub variants', (t) => {
const a = normalizeRegistryEndpoint('https://index.docker.io/v1/')
t.ok(a.isDockerHub)
t.is(a.baseUrl, 'https://registry-1.docker.io')
t.is(a.host, 'docker.io')
const b = normalizeRegistryEndpoint('docker.io')
t.ok(b.isDockerHub)
t.is(b.registryApiHost, 'registry-1.docker.io')
const c = normalizeRegistryEndpoint('')
t.ok(c.isDockerHub)
})
test('normalizeRegistryEndpoint private registry', (t) => {
const g = normalizeRegistryEndpoint('ghcr.io')
t.absent(g.isDockerHub)
t.is(g.baseUrl, 'https://ghcr.io')
t.is(g.host, 'ghcr.io')
const p = normalizeRegistryEndpoint('http://registry.local:5000')
t.is(p.baseUrl, 'http://registry.local:5000')
t.is(p.registryApiHost, 'registry.local:5000')
})
test('normalizeRegistryEndpoint rejects garbage', (t) => {
try {
normalizeRegistryEndpoint('http://[not-a-url')
t.fail('expected throw')
} catch (err) {
t.ok(/Invalid registry/i.test(err.message))
}
})
+6 -3
View File
@@ -23,8 +23,10 @@ const REQUIRED_IDS = [
'duplicate-always-pull',
'duplicate-container-form',
'images-view',
'images-panel-registries',
'registry-view',
'registry-store-form',
'registry-catalog-btn',
'registry-tags-table',
'pushImageModal',
'pull-image-credential',
'check-image-updates-btn',
@@ -34,7 +36,7 @@ const REQUIRED_IDS = [
'swarm-view',
'host-view',
'settings-view',
'peers-view',
'settings-panel-peers',
'connection-list',
'stack-git-url',
'tunnel-create-btn',
@@ -48,7 +50,8 @@ const REQUIRED_IDS = [
const REQUIRED_SNIPPETS = [
'data-view="tunnels"',
'data-view="swarm"',
'data-view="peers"',
'data-view="registry"',
'data-settings-tab="peers"',
'ENABLE_HOLESAIL',
'GitOps',
'collapse-sidebar-btn',
+25 -5
View File
@@ -1087,6 +1087,14 @@ export function showSettingsTab(tab) {
} catch {
// ignore
}
// Peers list lives under Settings — refresh when opening that tab
if (name === 'peers' && typeof window.loadPeersView === 'function') {
try {
window.loadPeersView()
} catch {
// ignore
}
}
}
/**
@@ -1123,7 +1131,10 @@ export function readSettingsForm() {
}
}
export function loadSettingsView() {
/**
* @param {string} [forceTab] — open a specific settings subtab (e.g. peers)
*/
export function loadSettingsView(forceTab) {
const s = loadSettings()
setSelectValue('settings-density', s.density || 'comfortable')
setSelectValue('settings-accent', s.accent || 'teal')
@@ -1143,7 +1154,7 @@ export function loadSettingsView() {
setSelectValue('settings-docker-term-theme', s.dockerTerminalTheme || 'dark')
setCheckbox('settings-first-connect', s.showFirstConnectTip !== false)
showSettingsTab(s.settingsTab || 'appearance')
showSettingsTab(forceTab || s.settingsTab || 'appearance')
const peer = document.getElementById('settings-peer-info')
const c = manager.active
@@ -1293,18 +1304,27 @@ export function openPalette(navigateToView) {
const items = [
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' },
{ label: 'Containers', icon: 'fa-cube', view: 'containers', keywords: 'g c' },
{ label: 'Images', icon: 'fa-layer-group', view: 'images', keywords: 'g i' },
{ label: 'Images', icon: 'fa-layer-group', view: 'images', keywords: 'g i local' },
{ label: 'Registry', icon: 'fa-warehouse', view: 'registry', keywords: 'g r vault hub catalog tags' },
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
{ label: 'Peers', icon: 'fa-network-wired', view: 'peers', keywords: 'g p' },
{
label: 'Peers',
icon: 'fa-network-wired',
keywords: 'g p connections hosts',
action: () => {
navigateToView('settings')
showSettingsTab('peers')
},
},
{ label: 'Events', icon: 'fa-bolt', view: 'events', keywords: 'g e' },
{ label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' },
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' },
{ label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a vault registry' },
{ label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a invites acl' },
{ label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' },
{
label: 'Create network (smart)',
+24 -6
View File
@@ -11,6 +11,7 @@ const GO_MAP = {
d: 'dashboard',
c: 'containers',
i: 'images',
r: 'registry',
n: 'networks',
v: 'volumes',
s: 'stacks',
@@ -18,7 +19,8 @@ const GO_MAP = {
e: 'events',
h: 'host',
t: 'tunnels',
p: 'peers',
/** Peers moved under Settings — handled specially in go-chord */
p: 'settings:peers',
f: 'fleet',
a: 'access',
',': 'settings',
@@ -111,7 +113,8 @@ export function openShortcutsModal() {
['g s / g w', 'Stacks / Swarm'],
['g o', 'Deploy'],
['g e / g h', 'Events / Host'],
['g t / g p / g f', 'Tunnels / Peers / Fleet'],
['g t / g p / g f', 'Tunnels / Peers (Settings) / Fleet'],
['g r', 'Registry'],
['g a / g ,', 'Access / Settings'],
],
},
@@ -312,11 +315,26 @@ export function initTrackGUx(ctx = {}) {
window.addEventListener('scroll', onScroll, { passive: true })
updateScrollTopVisibility()
const go = (view) => {
navigateToView?.(view)
const go = (dest) => {
if (!dest) return
// Compound targets: "settings:peers" → settings view + peers subtab
if (String(dest).includes(':')) {
const [view, tab] = String(dest).split(':')
navigateToView?.(view, tab ? { settingsTab: tab } : {})
if (tab && typeof window.peardockOps?.showSettingsTab === 'function') {
// ensure subtab after view paints
requestAnimationFrame(() => window.peardockOps.showSettingsTab(tab))
}
requestAnimationFrame(() => {
animateViewEnter(view)
markListRefreshed(view)
})
return
}
navigateToView?.(dest)
requestAnimationFrame(() => {
animateViewEnter(view)
markListRefreshed(view)
animateViewEnter(dest)
markListRefreshed(dest)
})
}