Move Peers into Settings and add a full Registry browser tab.
Release rolling / release (push) Successful in 9m39s
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:
@@ -953,9 +953,14 @@ function initNavigation() {
|
|||||||
function navigateToView(viewName, opts = {}) {
|
function navigateToView(viewName, opts = {}) {
|
||||||
const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts;
|
const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts;
|
||||||
|
|
||||||
// Peers list is always reachable (manage saved keys even when offline)
|
// Settings (incl. Peers subtab) is always reachable offline
|
||||||
const allowOffline =
|
// Legacy hash #/peers redirects into Settings → Peers
|
||||||
viewName === 'peers' || viewName === 'settings' || skipWelcomeGate;
|
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
|
// Without an active peer, keep the welcome card and block workspace views
|
||||||
if (!allowOffline && !hasActiveConnection()) {
|
if (!allowOffline && !hasActiveConnection()) {
|
||||||
@@ -1026,6 +1031,8 @@ function navigateToView(viewName, opts = {}) {
|
|||||||
}
|
}
|
||||||
} else if (viewName === 'images') {
|
} else if (viewName === 'images') {
|
||||||
loadImages();
|
loadImages();
|
||||||
|
} else if (viewName === 'registry') {
|
||||||
|
window.loadRegistryView?.() || window.refreshRegistryPanel?.();
|
||||||
} else if (viewName === 'networks') {
|
} else if (viewName === 'networks') {
|
||||||
loadNetworks();
|
loadNetworks();
|
||||||
} else if (viewName === 'volumes') {
|
} else if (viewName === 'volumes') {
|
||||||
@@ -1041,8 +1048,6 @@ function navigateToView(viewName, opts = {}) {
|
|||||||
if (typeof applyRoleUI === 'function') applyRoleUI();
|
if (typeof applyRoleUI === 'function') applyRoleUI();
|
||||||
} else if (viewName === 'fleet') {
|
} else if (viewName === 'fleet') {
|
||||||
loadFleetView();
|
loadFleetView();
|
||||||
} else if (viewName === 'peers') {
|
|
||||||
loadPeersView();
|
|
||||||
} else if (viewName === 'access') {
|
} else if (viewName === 'access') {
|
||||||
loadAccessView();
|
loadAccessView();
|
||||||
} else if (viewName === 'events') {
|
} else if (viewName === 'events') {
|
||||||
@@ -1052,7 +1057,7 @@ function navigateToView(viewName, opts = {}) {
|
|||||||
} else if (viewName === 'tunnels') {
|
} else if (viewName === 'tunnels') {
|
||||||
window.peardockOps?.loadTunnelsView?.();
|
window.peardockOps?.loadTunnelsView?.();
|
||||||
} else if (viewName === 'settings') {
|
} else if (viewName === 'settings') {
|
||||||
window.peardockOps?.loadSettingsView?.();
|
window.peardockOps?.loadSettingsView?.(opts.settingsTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser history / deep-links (skip when handling popstate)
|
// Browser history / deep-links (skip when handling popstate)
|
||||||
@@ -1825,8 +1830,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.querySelectorAll('.first-connect-link').forEach((btn) => {
|
document.querySelectorAll('.first-connect-link').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const view = btn.dataset.view;
|
const view = btn.dataset.view;
|
||||||
|
const settingsTab = btn.dataset.settingsTab;
|
||||||
dismissFirstConnectChecklist();
|
dismissFirstConnectChecklist();
|
||||||
if (view) navigateToView(view);
|
if (view) navigateToView(view, settingsTab ? { settingsTab } : {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -79,16 +79,16 @@ Sidebar `data-view="…"` sections, including:
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| containers | Table + details, lifecycle actions; **Add container** header button |
|
| containers | Table + details, lifecycle actions; **Add container** header button |
|
||||||
| add-container | Blank create form (always-pull, ports, advanced); not in sidebar |
|
| add-container | Blank create form (always-pull, ports, advanced); not in sidebar |
|
||||||
| images | Local images + Registries tab (vault, pull/push with credentials) |
|
| images | Local engine images (pull/push/build/prune) |
|
||||||
| images | Images, pull/build |
|
| registry | Vault, Hub search, remote catalog/tags/manifests/delete |
|
||||||
| networks / volumes | Resource management |
|
| networks / volumes | Resource management |
|
||||||
| stacks | Compose projects |
|
| stacks | Compose projects |
|
||||||
| deploy | Templates + container configuration form |
|
| deploy | Templates + container configuration form |
|
||||||
| swarm | Services, nodes, tasks, secrets, configs |
|
| swarm | Services, nodes, tasks, secrets, configs |
|
||||||
| tunnels | Holesail create/list/close |
|
| tunnels | Holesail create/list/close |
|
||||||
| host | System info / df / maintenance |
|
| host | System info / df / maintenance |
|
||||||
| peers / access | Fleet peers, invites, vault |
|
| access | Server peer ACL, invites, vault quick-store |
|
||||||
| settings | Density, accent, version, license |
|
| settings | Prefs + **Peers** subtab (client saved hosts) |
|
||||||
|
|
||||||
### Controllers
|
### Controllers
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@ Sidebar `data-view="…"` sections, including:
|
|||||||
| `ui/track-f-extras.js` | Extra polish features |
|
| `ui/track-f-extras.js` | Extra polish features |
|
||||||
| `libs/templateDeploy.js` | Deploy form / templates (large) |
|
| `libs/templateDeploy.js` | Deploy form / templates (large) |
|
||||||
| `libs/addContainer.js` | Add container blank create form |
|
| `libs/addContainer.js` | Add container blank create form |
|
||||||
|
| `libs/registryManager.js` | Registry view: vault, browser, pull/push helpers |
|
||||||
| `libs/terminal.js` etc. | xterm integration |
|
| `libs/terminal.js` etc. | xterm integration |
|
||||||
|
|
||||||
### Auto-refresh
|
### Auto-refresh
|
||||||
|
|||||||
+21
-4
@@ -54,17 +54,34 @@ Deploy templates form remains under the Deploy tab for catalog-driven deploys.
|
|||||||
|
|
||||||
## Images
|
## 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 |
|
| Capability | Notes |
|
||||||
|------------|--------|
|
|------------|--------|
|
||||||
| Local inventory | Filter used/unused, search, multi-tag display |
|
| Local inventory | Filter used/unused, search, multi-tag display |
|
||||||
| Pull | Modal with vault credential select; Hub search; re-pull from row |
|
| 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 |
|
| Push | Modal: choose tag, optional re-tag to registry namespace, vault credential; progress push |
|
||||||
| Tag / inspect / remove / build / prune | Existing actions |
|
| Tag / inspect / remove / build / prune | Local engine actions |
|
||||||
| Registry manager | Vault CRUD (encrypted), test auth, session login/logout, auto-match credential by image host |
|
|
||||||
|
|
||||||
**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
@@ -173,6 +173,12 @@
|
|||||||
<span class="nav-label">Images</span>
|
<span class="nav-label">Images</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
|
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
|
||||||
<i class="fas fa-hard-drive"></i>
|
<i class="fas fa-hard-drive"></i>
|
||||||
@@ -186,12 +192,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-group-label">Host & access</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">
|
<li class="nav-item">
|
||||||
<a href="#" class="nav-link" data-view="host" title="Host">
|
<a href="#" class="nav-link" data-view="host" title="Host">
|
||||||
<i class="fas fa-microchip"></i>
|
<i class="fas fa-microchip"></i>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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">
|
<div class="sidebar-footer">
|
||||||
<hr class="sidebar-divider">
|
<hr class="sidebar-divider">
|
||||||
<div class="connections-panel connections-panel--compact">
|
<div class="connections-panel connections-panel--compact">
|
||||||
@@ -287,50 +287,12 @@
|
|||||||
<ol class="first-connect-steps">
|
<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="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="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>
|
<li>Press <kbd>Ctrl</kbd>+<kbd>K</kbd> anytime for the command palette</li>
|
||||||
</ol>
|
</ol>
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="first-connect-got-it">Got it</button>
|
<button type="button" class="btn btn-primary btn-sm" id="first-connect-got-it">Got it</button>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Dashboard View — modern fit-to-viewport shell -->
|
||||||
<div id="dashboard-view" class="view hidden">
|
<div id="dashboard-view" class="view hidden">
|
||||||
<div class="dashboard-layout dash">
|
<div class="dashboard-layout dash">
|
||||||
@@ -1397,13 +1359,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Images View -->
|
<!-- Images View — local engine images only (remote registries: Registry tab) -->
|
||||||
<div id="images-view" class="view hidden">
|
<div id="images-view" class="view hidden">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2><i class="fas fa-layer-group"></i>Images</h2>
|
<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>
|
||||||
<div class="btn-group flex-wrap">
|
<div class="btn-group flex-wrap">
|
||||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#pullImageModal" data-min-role="operator">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#pullImageModal" data-min-role="operator">
|
||||||
@@ -1424,19 +1386,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div id="images-panel-local">
|
||||||
<!-- Image Filters -->
|
<!-- Image Filters -->
|
||||||
<div class="view-toolbar mb-3 d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
<div class="view-toolbar mb-3 d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
@@ -1494,21 +1443,153 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Registry manager -->
|
<!-- Registry View — vault, Hub search, remote catalog / tag browser -->
|
||||||
<div id="images-panel-registries" class="hidden">
|
<div id="registry-view" class="view hidden">
|
||||||
<div id="registry-session-status" class="alert alert-secondary small mb-3">Loading session…</div>
|
<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 & 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="row g-4">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card bg-dark border-secondary h-100">
|
<div class="card bg-dark border-secondary h-100">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fas fa-key me-2"></i>Vault credentials</span>
|
<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>
|
||||||
<div class="card-body">
|
<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>
|
<div id="registry-cred-list" class="mb-3"></div>
|
||||||
<hr class="border-secondary">
|
<hr class="border-secondary">
|
||||||
<h6 class="text-muted">Add registry</h6>
|
<h6 class="text-muted">Add registry</h6>
|
||||||
@@ -1541,7 +1622,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<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-header"><i class="fas fa-sign-in-alt me-2"></i>Session login</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="small text-muted">Log in for this session only (does not persist unless you also store).</p>
|
<p class="small text-muted">Log in for this session only (does not persist unless you also store).</p>
|
||||||
@@ -1569,14 +1650,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-dark border-secondary">
|
<div class="card bg-dark border-secondary mt-3">
|
||||||
<div class="card-header"><i class="fas fa-magnifying-glass me-2"></i>Docker Hub search</div>
|
<div class="card-header"><i class="fas fa-circle-info me-2"></i>Remote cleanup notes</div>
|
||||||
<div class="card-body">
|
<div class="card-body small text-muted">
|
||||||
<div class="input-group input-group-sm mb-2">
|
<ul class="mb-0 ps-3">
|
||||||
<input type="search" id="registry-hub-term" class="form-control bg-dark text-white" placeholder="Search Hub…" spellcheck="false" autocomplete="off">
|
<li>Deleting a tag resolves its manifest digest and issues <code>DELETE /v2/…/manifests/<digest></code>.</li>
|
||||||
<button type="button" class="btn btn-outline-info" id="registry-hub-search-btn">Search</button>
|
<li>Docker Hub and many SaaS registries disallow remote delete — use their UI or enable delete on self-hosted registries (Harbor, distribution, etc.).</li>
|
||||||
</div>
|
<li>After deletes, run the registry’s garbage collection if required for disk reclaim.</li>
|
||||||
<div id="registry-hub-results" class="list-group list-group-flush small" style="max-height: 240px; overflow: auto;"></div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2336,7 +2417,7 @@ services:
|
|||||||
<div class="card bg-dark border-secondary">
|
<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-header"><i class="fas fa-key me-2"></i>Registry vault</div>
|
||||||
<div class="card-body">
|
<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">
|
<form id="vault-store-form" class="mb-3">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -2353,7 +2434,7 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button class="btn btn-sm btn-primary" type="submit">Store encrypted</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -2564,6 +2645,9 @@ services:
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button type="button" class="nav-link" data-settings-tab="templates" role="tab">Templates</button>
|
<button type="button" class="nav-link" data-settings-tab="templates" role="tab">Templates</button>
|
||||||
</li>
|
</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">
|
<li class="nav-item" role="presentation">
|
||||||
<button type="button" class="nav-link" data-settings-tab="connections" role="tab">Connections</button>
|
<button type="button" class="nav-link" data-settings-tab="connections" role="tab">Connections</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -2755,11 +2839,58 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Connections -->
|
||||||
<div class="settings-panel hidden" id="settings-panel-connections" data-settings-panel="connections">
|
<div class="settings-panel hidden" id="settings-panel-connections" data-settings-panel="connections">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Active peer</h3>
|
<h3>Active peer</h3>
|
||||||
<div id="settings-peer-info" class="small text-muted">Not connected</div>
|
<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>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Server features</h3>
|
<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>
|
<label class="form-check-label" for="settings-first-connect">Show first-connect tip after adding a peer</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal -->
|
<!-- Terminal -->
|
||||||
|
|||||||
+552
-38
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Registry manager + image push/pull helpers for the Images view.
|
* Registry manager — vault, Hub search, remote V2 catalog/tags/manifest/delete.
|
||||||
* Uses the encrypted registry vault and session auth for private registries.
|
* Lives in the top-level Registry view (not under Images).
|
||||||
*/
|
*/
|
||||||
import { manager, Methods } from '../client/manager.js'
|
import { manager, Methods } from '../client/manager.js'
|
||||||
import { presentError } from '../client/errors.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} */
|
/** @type {{ authenticated?: boolean, username?: string|null, serveraddress?: string|null, credentialId?: string|null, label?: string|null }|null} */
|
||||||
let cachedAuthStatus = 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) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '')
|
return String(s ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -34,6 +49,35 @@ function escapeAttr(s) {
|
|||||||
return escapeHtml(s).replace(/'/g, ''')
|
return escapeHtml(s).replace(/'/g, ''')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[]>}
|
* @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() {
|
export async function refreshRegistryPanel() {
|
||||||
const listEl = document.getElementById('registry-cred-list')
|
const listEl = document.getElementById('registry-cred-list')
|
||||||
@@ -143,7 +215,8 @@ export async function refreshRegistryPanel() {
|
|||||||
if (cachedAuthStatus?.authenticated) {
|
if (cachedAuthStatus?.authenticated) {
|
||||||
const who = cachedAuthStatus.label || cachedAuthStatus.username || 'user'
|
const who = cachedAuthStatus.label || cachedAuthStatus.username || 'user'
|
||||||
const server = cachedAuthStatus.serveraddress || 'registry'
|
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 = `
|
statusEl.innerHTML = `
|
||||||
<span><i class="fas fa-check-circle me-1"></i>Session auth: <strong>${escapeHtml(who)}</strong>
|
<span><i class="fas fa-check-circle me-1"></i>Session auth: <strong>${escapeHtml(who)}</strong>
|
||||||
<span class="text-muted">@ ${escapeHtml(server)}</span></span>
|
<span class="text-muted">@ ${escapeHtml(server)}</span></span>
|
||||||
@@ -162,14 +235,14 @@ export async function refreshRegistryPanel() {
|
|||||||
} else {
|
} else {
|
||||||
statusEl.className = 'alert alert-secondary small mb-3'
|
statusEl.className = 'alert alert-secondary small mb-3'
|
||||||
statusEl.innerHTML =
|
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 (listEl) {
|
||||||
if (!cachedCredentials.length) {
|
if (!cachedCredentials.length) {
|
||||||
listEl.innerHTML =
|
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 {
|
} else {
|
||||||
listEl.innerHTML = cachedCredentials
|
listEl.innerHTML = cachedCredentials
|
||||||
.map(
|
.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">
|
<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>
|
<i class="fas fa-vial"></i><span class="d-none d-md-inline ms-1">Test</span>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</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) => {
|
listEl.querySelectorAll('.reg-del').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const ok = window.peardockOps?.confirmDestructive
|
const ok = window.peardockOps?.confirmDestructive
|
||||||
@@ -252,6 +341,372 @@ export async function refreshRegistryPanel() {
|
|||||||
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
|
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.
|
* Pull image with optional vault credential.
|
||||||
* @param {{ image: string, credentialId?: string }} args
|
* @param {{ image: string, credentialId?: string }} args
|
||||||
@@ -263,6 +718,7 @@ export async function pullImageWithAuth(args) {
|
|||||||
if (args.credentialId) body.credentialId = args.credentialId
|
if (args.credentialId) body.credentialId = args.credentialId
|
||||||
|
|
||||||
const host =
|
const host =
|
||||||
|
document.getElementById('registry-view') ||
|
||||||
document.getElementById('images-view') ||
|
document.getElementById('images-view') ||
|
||||||
document.getElementById('alert-container')?.parentElement ||
|
document.getElementById('alert-container')?.parentElement ||
|
||||||
document.body
|
document.body
|
||||||
@@ -307,6 +763,7 @@ export async function pushImageWithAuth(args) {
|
|||||||
|
|
||||||
const label = args.repo ? `${args.repo}:${args.tag || 'latest'}` : image
|
const label = args.repo ? `${args.repo}:${args.tag || 'latest'}` : image
|
||||||
const host =
|
const host =
|
||||||
|
document.getElementById('registry-view') ||
|
||||||
document.getElementById('images-view') ||
|
document.getElementById('images-view') ||
|
||||||
document.getElementById('alert-container')?.parentElement ||
|
document.getElementById('alert-container')?.parentElement ||
|
||||||
document.body
|
document.body
|
||||||
@@ -356,7 +813,6 @@ export async function openPushImageModal(opts = {}) {
|
|||||||
const credSelect = document.getElementById('push-image-credential')
|
const credSelect = document.getElementById('push-image-credential')
|
||||||
let tags = (opts.repoTags || []).filter((t) => t && t !== '<none>:<none>')
|
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)) {
|
if (!opts.id && !tags.length && Array.isArray(window.allImages)) {
|
||||||
const options = []
|
const options = []
|
||||||
for (const img of window.allImages) {
|
for (const img of window.allImages) {
|
||||||
@@ -402,7 +858,9 @@ export async function openPushImageModal(opts = {}) {
|
|||||||
}
|
}
|
||||||
if (repoInput) {
|
if (repoInput) {
|
||||||
const def = opts.defaultRef || tags[0] || ''
|
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
|
repoInput.value = r.startsWith('sha256') ? '' : r
|
||||||
if (tagInput) tagInput.value = t || 'latest'
|
if (tagInput) tagInput.value = t || 'latest'
|
||||||
}
|
}
|
||||||
@@ -410,8 +868,7 @@ export async function openPushImageModal(opts = {}) {
|
|||||||
fillCredentialSelect(credSelect)
|
fillCredentialSelect(credSelect)
|
||||||
togglePushRetagFields()
|
togglePushRetagFields()
|
||||||
|
|
||||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl)
|
bootstrap.Modal.getOrCreateInstance(modalEl).show()
|
||||||
modal.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePushRetagFields() {
|
function togglePushRetagFields() {
|
||||||
@@ -420,32 +877,67 @@ function togglePushRetagFields() {
|
|||||||
if (wrap) wrap.style.display = retag ? '' : 'none'
|
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.
|
* Wire DOM once.
|
||||||
*/
|
*/
|
||||||
export function initRegistryManager() {
|
export function initRegistryManager() {
|
||||||
// Subtabs
|
document.querySelectorAll('[data-registry-tab]').forEach((btn) => {
|
||||||
document.querySelectorAll('[data-images-tab]').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
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
|
// Store credential form
|
||||||
const storeForm = document.getElementById('registry-store-form')
|
const storeForm = document.getElementById('registry-store-form')
|
||||||
if (storeForm && !storeForm.dataset.wired) {
|
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')
|
const loginForm = document.getElementById('registry-login-form')
|
||||||
if (loginForm && !loginForm.dataset.wired) {
|
if (loginForm && !loginForm.dataset.wired) {
|
||||||
loginForm.dataset.wired = '1'
|
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 hubBtn = document.getElementById('registry-hub-search-btn')
|
||||||
const hubTerm = document.getElementById('registry-hub-term')
|
const hubTerm = document.getElementById('registry-hub-term')
|
||||||
if (hubBtn && !hubBtn.dataset.wired) {
|
if (hubBtn && !hubBtn.dataset.wired) {
|
||||||
@@ -532,14 +1024,26 @@ export function initRegistryManager() {
|
|||||||
const name = r.name || r.Name || ''
|
const name = r.name || r.Name || ''
|
||||||
const stars = r.star_count ?? r.starCount ?? 0
|
const stars = r.star_count ?? r.starCount ?? 0
|
||||||
const desc = r.description || r.Description || ''
|
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)}">
|
return `<div class="list-group-item bg-dark text-white border-secondary">
|
||||||
<div class="d-flex justify-content-between"><strong class="font-monospace">${escapeHtml(name)}</strong>
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||||
<span class="badge bg-secondary">${stars} ★</span></div>
|
<div class="min-w-0">
|
||||||
<div class="small text-muted text-truncate">${escapeHtml(desc)}</div>
|
<strong class="font-monospace">${escapeHtml(name)}</strong>
|
||||||
</button>`
|
<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('')
|
.join('')
|
||||||
out.querySelectorAll('.hub-pick').forEach((btn) => {
|
out.querySelectorAll('.hub-pull').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const name = btn.dataset.name
|
const name = btn.dataset.name
|
||||||
const pullName = document.getElementById('pull-image-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) {
|
} catch (err) {
|
||||||
out.innerHTML = `<div class="text-danger small p-2">${escapeHtml(err.message)}</div>`
|
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')
|
const pullModal = document.getElementById('pullImageModal')
|
||||||
pullModal?.addEventListener('show.bs.modal', () => {
|
pullModal?.addEventListener('show.bs.modal', () => {
|
||||||
preparePullModal()
|
preparePullModal()
|
||||||
@@ -617,13 +1129,15 @@ export function initRegistryManager() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for app.js action buttons
|
|
||||||
window.openPushImageModal = openPushImageModal
|
window.openPushImageModal = openPushImageModal
|
||||||
window.pullImageWithAuth = pullImageWithAuth
|
window.pullImageWithAuth = pullImageWithAuth
|
||||||
window.pushImageWithAuth = pushImageWithAuth
|
window.pushImageWithAuth = pushImageWithAuth
|
||||||
window.refreshRegistryPanel = refreshRegistryPanel
|
window.refreshRegistryPanel = refreshRegistryPanel
|
||||||
|
window.loadRegistryView = loadRegistryView
|
||||||
window.switchImagesTab = switchImagesTab
|
window.switchImagesTab = switchImagesTab
|
||||||
|
window.switchRegistryTab = switchRegistryTab
|
||||||
window.preparePullModal = preparePullModal
|
window.preparePullModal = preparePullModal
|
||||||
|
window.openRegistryRepository = openRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { registerSwarmHandlers } from '../handlers/swarm.js'
|
|||||||
import { registerPluginHandlers } from '../handlers/plugins.js'
|
import { registerPluginHandlers } from '../handlers/plugins.js'
|
||||||
import { registerPeerHandlers } from '../handlers/peers.js'
|
import { registerPeerHandlers } from '../handlers/peers.js'
|
||||||
import { registerVaultHandlers } from '../handlers/vault.js'
|
import { registerVaultHandlers } from '../handlers/vault.js'
|
||||||
|
import { registerRegistryHandlers } from '../handlers/registry.js'
|
||||||
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
||||||
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
||||||
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
||||||
@@ -39,6 +40,7 @@ export function registerAllHandlers(session) {
|
|||||||
registerPluginHandlers(session)
|
registerPluginHandlers(session)
|
||||||
registerPeerHandlers(session)
|
registerPeerHandlers(session)
|
||||||
registerVaultHandlers(session)
|
registerVaultHandlers(session)
|
||||||
|
registerRegistryHandlers(session)
|
||||||
registerBinaryStreamHandlers(session)
|
registerBinaryStreamHandlers(session)
|
||||||
registerSuggestionHandlers(session)
|
registerSuggestionHandlers(session)
|
||||||
registerTunnelHandlers(session)
|
registerTunnelHandlers(session)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ export const MethodRoles = Object.freeze({
|
|||||||
getAuthStatus: Roles.viewer,
|
getAuthStatus: Roles.viewer,
|
||||||
listVaultCredentials: Roles.viewer,
|
listVaultCredentials: Roles.viewer,
|
||||||
vaultTestCredential: Roles.operator,
|
vaultTestCredential: Roles.operator,
|
||||||
|
registryCatalog: Roles.viewer,
|
||||||
|
registryListTags: Roles.viewer,
|
||||||
|
registryGetManifest: Roles.viewer,
|
||||||
|
registryNormalizeEndpoint: Roles.viewer,
|
||||||
listPeers: Roles.viewer,
|
listPeers: Roles.viewer,
|
||||||
/** Invite strings are secrets — admin only (not viewer/operator read-only) */
|
/** Invite strings are secrets — admin only (not viewer/operator read-only) */
|
||||||
listInvites: Roles.admin,
|
listInvites: Roles.admin,
|
||||||
@@ -128,6 +132,8 @@ export const MethodRoles = Object.freeze({
|
|||||||
vaultUseCredential: Roles.operator,
|
vaultUseCredential: Roles.operator,
|
||||||
vaultClearSession: Roles.operator,
|
vaultClearSession: Roles.operator,
|
||||||
registryLogout: Roles.operator,
|
registryLogout: Roles.operator,
|
||||||
|
registryDeleteTag: Roles.admin,
|
||||||
|
registryDeleteTags: Roles.admin,
|
||||||
createService: Roles.operator,
|
createService: Roles.operator,
|
||||||
updateService: Roles.operator,
|
updateService: Roles.operator,
|
||||||
scaleService: Roles.operator,
|
scaleService: Roles.operator,
|
||||||
@@ -308,6 +314,14 @@ export const Methods = Object.freeze({
|
|||||||
vaultTestCredential: 'vaultTestCredential',
|
vaultTestCredential: 'vaultTestCredential',
|
||||||
vaultClearSession: 'vaultClearSession',
|
vaultClearSession: 'vaultClearSession',
|
||||||
|
|
||||||
|
// Remote registry browser (Registry HTTP API V2)
|
||||||
|
registryCatalog: 'registryCatalog',
|
||||||
|
registryListTags: 'registryListTags',
|
||||||
|
registryGetManifest: 'registryGetManifest',
|
||||||
|
registryDeleteTag: 'registryDeleteTag',
|
||||||
|
registryDeleteTags: 'registryDeleteTags',
|
||||||
|
registryNormalizeEndpoint: 'registryNormalizeEndpoint',
|
||||||
|
|
||||||
// Peer ACL
|
// Peer ACL
|
||||||
listPeers: 'listPeers',
|
listPeers: 'listPeers',
|
||||||
invitePeer: 'invitePeer',
|
invitePeer: 'invitePeer',
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -23,8 +23,10 @@ const REQUIRED_IDS = [
|
|||||||
'duplicate-always-pull',
|
'duplicate-always-pull',
|
||||||
'duplicate-container-form',
|
'duplicate-container-form',
|
||||||
'images-view',
|
'images-view',
|
||||||
'images-panel-registries',
|
'registry-view',
|
||||||
'registry-store-form',
|
'registry-store-form',
|
||||||
|
'registry-catalog-btn',
|
||||||
|
'registry-tags-table',
|
||||||
'pushImageModal',
|
'pushImageModal',
|
||||||
'pull-image-credential',
|
'pull-image-credential',
|
||||||
'check-image-updates-btn',
|
'check-image-updates-btn',
|
||||||
@@ -34,7 +36,7 @@ const REQUIRED_IDS = [
|
|||||||
'swarm-view',
|
'swarm-view',
|
||||||
'host-view',
|
'host-view',
|
||||||
'settings-view',
|
'settings-view',
|
||||||
'peers-view',
|
'settings-panel-peers',
|
||||||
'connection-list',
|
'connection-list',
|
||||||
'stack-git-url',
|
'stack-git-url',
|
||||||
'tunnel-create-btn',
|
'tunnel-create-btn',
|
||||||
@@ -48,7 +50,8 @@ const REQUIRED_IDS = [
|
|||||||
const REQUIRED_SNIPPETS = [
|
const REQUIRED_SNIPPETS = [
|
||||||
'data-view="tunnels"',
|
'data-view="tunnels"',
|
||||||
'data-view="swarm"',
|
'data-view="swarm"',
|
||||||
'data-view="peers"',
|
'data-view="registry"',
|
||||||
|
'data-settings-tab="peers"',
|
||||||
'ENABLE_HOLESAIL',
|
'ENABLE_HOLESAIL',
|
||||||
'GitOps',
|
'GitOps',
|
||||||
'collapse-sidebar-btn',
|
'collapse-sidebar-btn',
|
||||||
|
|||||||
+25
-5
@@ -1087,6 +1087,14 @@ export function showSettingsTab(tab) {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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()
|
const s = loadSettings()
|
||||||
setSelectValue('settings-density', s.density || 'comfortable')
|
setSelectValue('settings-density', s.density || 'comfortable')
|
||||||
setSelectValue('settings-accent', s.accent || 'teal')
|
setSelectValue('settings-accent', s.accent || 'teal')
|
||||||
@@ -1143,7 +1154,7 @@ export function loadSettingsView() {
|
|||||||
setSelectValue('settings-docker-term-theme', s.dockerTerminalTheme || 'dark')
|
setSelectValue('settings-docker-term-theme', s.dockerTerminalTheme || 'dark')
|
||||||
setCheckbox('settings-first-connect', s.showFirstConnectTip !== false)
|
setCheckbox('settings-first-connect', s.showFirstConnectTip !== false)
|
||||||
|
|
||||||
showSettingsTab(s.settingsTab || 'appearance')
|
showSettingsTab(forceTab || s.settingsTab || 'appearance')
|
||||||
|
|
||||||
const peer = document.getElementById('settings-peer-info')
|
const peer = document.getElementById('settings-peer-info')
|
||||||
const c = manager.active
|
const c = manager.active
|
||||||
@@ -1293,18 +1304,27 @@ export function openPalette(navigateToView) {
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' },
|
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' },
|
||||||
{ label: 'Containers', icon: 'fa-cube', view: 'containers', keywords: 'g c' },
|
{ 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: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
|
||||||
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
|
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
|
||||||
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
|
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
|
||||||
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
|
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
|
||||||
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
|
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
|
||||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
{ 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: 'Events', icon: 'fa-bolt', view: 'events', keywords: 'g e' },
|
||||||
{ label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' },
|
{ label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' },
|
||||||
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' },
|
{ 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: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' },
|
||||||
{
|
{
|
||||||
label: 'Create network (smart)',
|
label: 'Create network (smart)',
|
||||||
|
|||||||
+24
-6
@@ -11,6 +11,7 @@ const GO_MAP = {
|
|||||||
d: 'dashboard',
|
d: 'dashboard',
|
||||||
c: 'containers',
|
c: 'containers',
|
||||||
i: 'images',
|
i: 'images',
|
||||||
|
r: 'registry',
|
||||||
n: 'networks',
|
n: 'networks',
|
||||||
v: 'volumes',
|
v: 'volumes',
|
||||||
s: 'stacks',
|
s: 'stacks',
|
||||||
@@ -18,7 +19,8 @@ const GO_MAP = {
|
|||||||
e: 'events',
|
e: 'events',
|
||||||
h: 'host',
|
h: 'host',
|
||||||
t: 'tunnels',
|
t: 'tunnels',
|
||||||
p: 'peers',
|
/** Peers moved under Settings — handled specially in go-chord */
|
||||||
|
p: 'settings:peers',
|
||||||
f: 'fleet',
|
f: 'fleet',
|
||||||
a: 'access',
|
a: 'access',
|
||||||
',': 'settings',
|
',': 'settings',
|
||||||
@@ -111,7 +113,8 @@ export function openShortcutsModal() {
|
|||||||
['g s / g w', 'Stacks / Swarm'],
|
['g s / g w', 'Stacks / Swarm'],
|
||||||
['g o', 'Deploy'],
|
['g o', 'Deploy'],
|
||||||
['g e / g h', 'Events / Host'],
|
['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'],
|
['g a / g ,', 'Access / Settings'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -312,11 +315,26 @@ export function initTrackGUx(ctx = {}) {
|
|||||||
window.addEventListener('scroll', onScroll, { passive: true })
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
updateScrollTopVisibility()
|
updateScrollTopVisibility()
|
||||||
|
|
||||||
const go = (view) => {
|
const go = (dest) => {
|
||||||
navigateToView?.(view)
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
animateViewEnter(view)
|
animateViewEnter(dest)
|
||||||
markListRefreshed(view)
|
markListRefreshed(dest)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user