Updates
This commit is contained in:
@@ -4,7 +4,7 @@ Official marketing site and documentation for [peardock](https://peardock.boats)
|
|||||||
([source](https://git.ssh.surf/snxraven/peardock)).
|
([source](https://git.ssh.surf/snxraven/peardock)).
|
||||||
|
|
||||||
Code-level architecture docs live in the **peardock** repo under `docs/`.
|
Code-level architecture docs live in the **peardock** repo under `docs/`.
|
||||||
This site is the public operator mirror (HTML) plus marketing pages — keep claims in sync when the product changes.
|
This site is the public operator mirror (HTML) plus marketing pages. Keep claims in sync when the product changes.
|
||||||
|
|
||||||
## Install story (keep in sync with main repo)
|
## Install story (keep in sync with main repo)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ When peardock ships UI or RPC surface changes, update:
|
|||||||
|
|
||||||
Especially check claims for:
|
Especially check claims for:
|
||||||
|
|
||||||
- **Registry** view (vault + browser) — not “Access → Registry vault”
|
- **Registry** view (vault + browser). Not under “Access → Registry vault”.
|
||||||
- **Settings → Peers** vs **Access** (ACL/invites)
|
- **Settings → Peers** vs **Access** (ACL/invites)
|
||||||
- Image update indicators / `checkImageUpdates`
|
- Image update indicators / `checkImageUpdates`
|
||||||
- Job tray + hybrid pull progress
|
- Job tray + hybrid pull progress
|
||||||
|
|||||||
+4
-4
@@ -120,7 +120,7 @@
|
|||||||
<h2>Handshake</h2>
|
<h2>Handshake</h2>
|
||||||
<p>On connect, the client calls <code>handshake</code>. The server returns:</p>
|
<p>On connect, the client calls <code>handshake</code>. The server returns:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Negotiated <code>PROTOCOL_VERSION</code> (currently <strong>3</strong> — HMAC auth with capability + adminProof)</li>
|
<li>Negotiated <code>PROTOCOL_VERSION</code> (currently <strong>3</strong>, HMAC auth with capability and adminProof)</li>
|
||||||
<li><code>peerId</code> for this connection (stable client public key hex)</li>
|
<li><code>peerId</code> for this connection (stable client public key hex)</li>
|
||||||
<li>Role (viewer / operator / admin) and <code>auth.mode</code></li>
|
<li>Role (viewer / operator / admin) and <code>auth.mode</code></li>
|
||||||
<li>Feature flags (swarm, plugins, holesail, …)</li>
|
<li>Feature flags (swarm, plugins, holesail, …)</li>
|
||||||
@@ -157,9 +157,9 @@
|
|||||||
<h3>Containers</h3>
|
<h3>Containers</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>listContainers</code>, <code>inspectContainer</code>, start, stop, kill, restart, pause/unpause</li>
|
<li><code>listContainers</code>, <code>inspectContainer</code>, start, stop, kill, restart, pause/unpause</li>
|
||||||
<li><code>checkImageUpdates</code> — compare local image digests to remote registry manifests (viewer)</li>
|
<li><code>checkImageUpdates</code>: compare local image digests to remote registry manifests (viewer)</li>
|
||||||
<li><code>deployContainer</code> (supports <code>alwaysPull</code>), <code>recreateContainer</code>, <code>updateContainer</code>, <code>duplicateContainer</code></li>
|
<li><code>deployContainer</code> (supports <code>alwaysPull</code>), <code>recreateContainer</code>, <code>updateContainer</code>, <code>duplicateContainer</code></li>
|
||||||
<li><code>removeContainer</code> — graceful stop (short grace + kill fallback) before delete so force-remove does not hang</li>
|
<li><code>removeContainer</code>: graceful stop (short grace then kill fallback) before delete so force remove does not hang</li>
|
||||||
<li><code>bulkContainerOperation</code>, <code>containerTop</code>, archive, rename</li>
|
<li><code>bulkContainerOperation</code>, <code>containerTop</code>, archive, rename</li>
|
||||||
<li><code>startTerminal</code> / terminal I/O; <code>getContainerLogs</code> / log follow</li>
|
<li><code>startTerminal</code> / terminal I/O; <code>getContainerLogs</code> / log follow</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<code>listInvites</code>, <code>invitePeer</code>, <code>deleteInvite</code>,
|
<code>listInvites</code>, <code>invitePeer</code>, <code>deleteInvite</code>,
|
||||||
<code>revokePeer</code>, <code>unrevokePeer</code>, <code>listRevokedPeers</code>,
|
<code>revokePeer</code>, <code>unrevokePeer</code>, <code>listRevokedPeers</code>,
|
||||||
<code>clearRevokedPeers</code>, <code>setPeerRole</code> — <strong>admin only</strong>
|
<code>clearRevokedPeers</code>, <code>setPeerRole</code> (<strong>admin only</strong>)
|
||||||
(invite strings are secrets)
|
(invite strings are secrets)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+6
-6
@@ -124,8 +124,8 @@
|
|||||||
|
|
||||||
<h2>What do I share with clients?</h2>
|
<h2>What do I share with clients?</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Viewers:</strong> <code>SERVER_PUBLIC_KEY</code> only (64 hex) — read-only.</li>
|
<li><strong>Viewers:</strong> <code>SERVER_PUBLIC_KEY</code> only (64 hex). Read-only.</li>
|
||||||
<li><strong>Admins:</strong> public key + <code>SERVER_SEED</code> (from the same <code>.env</code>) — never send the seed over chat to operators.</li>
|
<li><strong>Admins:</strong> public key plus <code>SERVER_SEED</code> from the same <code>.env</code>. Never send the seed over chat to operators.</li>
|
||||||
<li><strong>Operators:</strong> a full <code>pd1.…</code> invite from Access → Create invite (embeds key + capability; no seed).</li>
|
<li><strong>Operators:</strong> a full <code>pd1.…</code> invite from Access → Create invite (embeds key + capability; no seed).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Never share <code>SERVER_SEED</code> with operators.</p>
|
<p>Never share <code>SERVER_SEED</code> with operators.</p>
|
||||||
@@ -149,8 +149,8 @@ sudo journalctl -u peardock -n 80 --no-pager | grep -i 'public key'</code></pre>
|
|||||||
<p>
|
<p>
|
||||||
As admin (public key + <code>SERVER_SEED</code> in Add peer), open <strong>Access → Create invite</strong>
|
As admin (public key + <code>SERVER_SEED</code> in Add peer), open <strong>Access → Create invite</strong>
|
||||||
and copy the full string starting with <code>pd1.</code>. The operator pastes that entire string in Add peer.
|
and copy the full string starting with <code>pd1.</code>. The operator pastes that entire string in Add peer.
|
||||||
Public key and HMAC capability are embedded — no Autopass/RocksDB and no seed sharing.
|
Public key and HMAC capability are embedded. No Autopass or RocksDB. No seed sharing.
|
||||||
Invites can be persistent (default) or limited by TTL / max uses. Revoke peers from Access when devices are lost.
|
Invites can be persistent (default) or limited by TTL or max uses. Revoke peers from Access when devices are lost.
|
||||||
Deep dive: <a href="/docs/security">Security model</a>.
|
Deep dive: <a href="/docs/security">Security model</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ npm run sign:macos -- out/peardock-darwin-arm64/peardock.app</code></pre>
|
|||||||
<h2>Where did Peers go? Where are saved hosts?</h2>
|
<h2>Where did Peers go? Where are saved hosts?</h2>
|
||||||
<p>
|
<p>
|
||||||
<strong>Settings → Peers</strong> manages client-side saved hosts (public keys, aliases).
|
<strong>Settings → Peers</strong> manages client-side saved hosts (public keys, aliases).
|
||||||
Server-side ACL — invites, revoke, roles — stays under <strong>Access</strong>.
|
Server-side ACL for invites, revoke, and roles stays under <strong>Access</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>What does the Updates column on containers mean?</h2>
|
<h2>What does the Updates column on containers mean?</h2>
|
||||||
@@ -226,7 +226,7 @@ npm run sign:macos -- out/peardock-darwin-arm64/peardock.app</code></pre>
|
|||||||
<p>
|
<p>
|
||||||
Pull and push stream layer events over <code>push:pullProgress</code> /
|
Pull and push stream layer events over <code>push:pullProgress</code> /
|
||||||
<code>push:pushProgress</code>. The desktop job tray aggregates them into hybrid overall
|
<code>push:pushProgress</code>. The desktop job tray aggregates them into hybrid overall
|
||||||
progress, phase labels, and a fixed-height layer track — not a single misleading percentage.
|
progress, phase labels, and a fixed-height layer track. Not a single misleading percentage.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>How do I update?</h2>
|
<h2>How do I update?</h2>
|
||||||
|
|||||||
+1
-1
@@ -171,7 +171,7 @@
|
|||||||
The server listens on a HyperDHT keypair. Clients connect with its public key.
|
The server listens on a HyperDHT keypair. Clients connect with its public key.
|
||||||
Transport is Noise-encrypted. The desktop UI is an Electron (or Pear) app with a full
|
Transport is Noise-encrypted. The desktop UI is an Electron (or Pear) app with a full
|
||||||
Engine surface: containers, registry browser, templates, stacks, Swarm, job tray,
|
Engine surface: containers, registry browser, templates, stacks, Swarm, job tray,
|
||||||
and Holesail tunnels — without SaaS tenancy or remote-access headaches.
|
and Holesail tunnels. No SaaS tenancy. No remote access headaches.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
|
|||||||
+3
-3
@@ -127,7 +127,7 @@ sudo journalctl -u peardock -f</code></pre>
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>SERVER_PUBLIC_KEY</code></td><td>Safe to share</td><td>Alone → <strong>viewer</strong></td></tr>
|
<tr><td><code>SERVER_PUBLIC_KEY</code></td><td>Safe to share</td><td>Alone → <strong>viewer</strong></td></tr>
|
||||||
<tr><td><code>SERVER_SEED</code></td><td>Admins only — never share with operators</td><td>With public key → <strong>admin</strong></td></tr>
|
<tr><td><code>SERVER_SEED</code></td><td>Admins only. Never share with operators.</td><td>With public key → <strong>admin</strong></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +222,7 @@ PEARDOCK_BROWSE_ROOTS=/var/lib/docker/volumes
|
|||||||
PEARDOCK_AUDIT=1</code></pre>
|
PEARDOCK_AUDIT=1</code></pre>
|
||||||
<ol start="4">
|
<ol start="4">
|
||||||
<li>As admin: <strong>Access → Create invite</strong> → copy the full <code>pd1.…</code> invite string (never truncate).</li>
|
<li>As admin: <strong>Access → Create invite</strong> → copy the full <code>pd1.…</code> invite string (never truncate).</li>
|
||||||
<li>Operator pastes the full <code>pd1.</code> invite in <strong>Add peer</strong> — the client decodes public key + HMAC capability and connects with the granted role (no seed, no RocksDB/Autopass).</li>
|
<li>Operator pastes the full <code>pd1.</code> invite in <strong>Add peer</strong>. The client decodes public key and HMAC capability, then connects with the granted role (no seed, no RocksDB or Autopass).</li>
|
||||||
<li><strong>Revoke</strong> lost devices from Access. Delete spent invites before minting a replacement if needed.</li>
|
<li><strong>Revoke</strong> lost devices from Access. Delete spent invites before minting a replacement if needed.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -245,7 +245,7 @@ PEARDOCK_AUDIT=1</code></pre>
|
|||||||
<h2>Registry vault and browser</h2>
|
<h2>Registry vault and browser</h2>
|
||||||
<p>
|
<p>
|
||||||
Credentials are encrypted at rest (AES-GCM; KDF is Bare-safe).
|
Credentials are encrypted at rest (AES-GCM; KDF is Bare-safe).
|
||||||
The desktop app has a top-level <strong>Registry</strong> view — not under Access.
|
The desktop app has a top-level <strong>Registry</strong> view. It is not under Access.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ sudo journalctl -u peardock -f</code></pre>
|
|||||||
After the first successful start, the server writes identity into
|
After the first successful start, the server writes identity into
|
||||||
<code>/opt/peardock/.env</code>. <strong>Open that file</strong> (or grep it) before connecting:
|
<code>/opt/peardock/.env</code>. <strong>Open that file</strong> (or grep it) before connecting:
|
||||||
</p>
|
</p>
|
||||||
<pre><code># Preferred — both keys after first start
|
<pre><code># Preferred: both keys after first start
|
||||||
sudo grep -E '^(SERVER_PUBLIC_KEY|SERVER_SEED)=' /opt/peardock/.env
|
sudo grep -E '^(SERVER_PUBLIC_KEY|SERVER_SEED)=' /opt/peardock/.env
|
||||||
# or: sudo cat /opt/peardock/.env
|
# or: sudo cat /opt/peardock/.env
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ sudo journalctl -u peardock -n 80 --no-pager | grep -i 'public key'</code></pre>
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>SERVER_SEED</code></td>
|
<td><code>SERVER_SEED</code></td>
|
||||||
<td><strong>Admins only</strong> — never give to operators</td>
|
<td><strong>Admins only.</strong> Never give to operators.</td>
|
||||||
<td>Paste with the public key → <strong>admin</strong></td>
|
<td>Paste with the public key → <strong>admin</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -247,7 +247,7 @@ flowchart LR
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Public key only (<code>SERVER_PUBLIC_KEY</code>)</td><td><strong>Viewer</strong> (read-only)</td></tr>
|
<tr><td>Public key only (<code>SERVER_PUBLIC_KEY</code>)</td><td><strong>Viewer</strong> (read-only)</td></tr>
|
||||||
<tr><td>Public key + <code>SERVER_SEED</code> (from the same <code>.env</code>)</td><td><strong>Admin</strong></td></tr>
|
<tr><td>Public key + <code>SERVER_SEED</code> (from the same <code>.env</code>)</td><td><strong>Admin</strong></td></tr>
|
||||||
<tr><td>Full <code>pd1.…</code> invite (from Access → Create invite)</td><td>Role in the invite — <strong>no seed sharing</strong></td></tr>
|
<tr><td>Full <code>pd1.…</code> invite (from Access → Create invite)</td><td>Role in the invite. <strong>No seed sharing.</strong></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -130,7 +130,7 @@
|
|||||||
<p>
|
<p>
|
||||||
PearDock is a <strong>self-hosted P2P Docker control plane</strong>. There is no central login.
|
PearDock is a <strong>self-hosted P2P Docker control plane</strong>. There is no central login.
|
||||||
Anyone who knows the <strong>server public key</strong> can attempt a HyperDHT dial.
|
Anyone who knows the <strong>server public key</strong> can attempt a HyperDHT dial.
|
||||||
That alone must not mean full Docker control — so the default role is <strong>viewer</strong> (read-only).
|
That alone must not mean full Docker control. The default role is <strong>viewer</strong> (read-only).
|
||||||
</p>
|
</p>
|
||||||
<pre><code>[Desktop client] --Noise/HyperDHT--> [peardock-server] --unix socket--> [dockerd]
|
<pre><code>[Desktop client] --Noise/HyperDHT--> [peardock-server] --unix socket--> [dockerd]
|
||||||
|
|
|
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Public key only (<code>SERVER_PUBLIC_KEY</code>)</td><td><strong>Viewer</strong> (read-only)</td></tr>
|
<tr><td>Public key only (<code>SERVER_PUBLIC_KEY</code>)</td><td><strong>Viewer</strong> (read-only)</td></tr>
|
||||||
<tr><td>Public key + <code>SERVER_SEED</code></td><td><strong>Admin</strong> (seed never sent on the wire)</td></tr>
|
<tr><td>Public key + <code>SERVER_SEED</code></td><td><strong>Admin</strong> (seed never sent on the wire)</td></tr>
|
||||||
<tr><td>Full <code>pd1.…</code> invite</td><td>Role in the invite — <strong>no seed sharing</strong></td></tr>
|
<tr><td>Full <code>pd1.…</code> invite</td><td>Role in the invite. <strong>No seed sharing.</strong></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,8 +228,8 @@
|
|||||||
<h2 id="handshake">4. Handshake elevation</h2>
|
<h2 id="handshake">4. Handshake elevation</h2>
|
||||||
<p>Baseline role (usually viewer) is resolved from env / admin keys / peer policy. Then handshake may elevate:</p>
|
<p>Baseline role (usually viewer) is resolved from env / admin keys / peer policy. Then handshake may elevate:</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>adminProof</strong> — seed ownership → admin</li>
|
<li><strong>adminProof</strong>: seed ownership maps to admin</li>
|
||||||
<li><strong>capability</strong> — HMAC grant from a <code>pd1.</code> invite (or direct token)</li>
|
<li><strong>capability</strong>: HMAC grant from a <code>pd1.</code> invite (or direct token)</li>
|
||||||
<li>Otherwise stay at baseline</li>
|
<li>Otherwise stay at baseline</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="callout warn">
|
<div class="callout warn">
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Optional: <code>PEARDOCK_ADMIN_KEYS</code> (fixed admin peer ids),
|
Optional: <code>PEARDOCK_ADMIN_KEYS</code> (fixed admin peer ids),
|
||||||
<code>PEARDOCK_DEFAULT_ROLE</code>, <code>PEARDOCK_PEER_ALLOWLIST=1</code>,
|
<code>PEARDOCK_DEFAULT_ROLE</code>, <code>PEARDOCK_PEER_ALLOWLIST=1</code>,
|
||||||
<code>PEARDOCK_INSECURE_OPEN_ADMIN=1</code> (dev only — everyone is admin).
|
<code>PEARDOCK_INSECURE_OPEN_ADMIN=1</code> (dev only; everyone is admin).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="admin">5. Admin seed proof</h2>
|
<h2 id="admin">5. Admin seed proof</h2>
|
||||||
@@ -305,10 +305,10 @@
|
|||||||
<h2 id="revoke">7. Registration, reconnect, revoke</h2>
|
<h2 id="revoke">7. Registration, reconnect, revoke</h2>
|
||||||
<p>State lives in <code>peardock-peers.json</code> (override with <code>PEARDOCK_PEER_POLICY</code>):</p>
|
<p>State lives in <code>peardock-peers.json</code> (override with <code>PEARDOCK_PEER_POLICY</code>):</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>peers</code> — registered elevated client identities for reconnect</li>
|
<li><code>peers</code>: registered elevated client identities for reconnect</li>
|
||||||
<li><code>capabilities</code> — active grants (by jti)</li>
|
<li><code>capabilities</code>: active grants (by jti)</li>
|
||||||
<li><code>spentJtis</code> — deleted or exhausted grants</li>
|
<li><code>spentJtis</code>: deleted or exhausted grants</li>
|
||||||
<li><code>revoked</code> — hard ban list of peer ids</li>
|
<li><code>revoked</code>: hard ban list of peer ids</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
After a successful elevate, reconnect can use the registered role even if that invite jti is later deleted.
|
After a successful elevate, reconnect can use the registered role even if that invite jti is later deleted.
|
||||||
|
|||||||
+2
-2
@@ -147,12 +147,12 @@ curl -fsSL https://install.peardock.boats | bash -s -- --both --yes</code></pre>
|
|||||||
<li>Installs binary to <code>/opt/peardock/peardock-server</code></li>
|
<li>Installs binary to <code>/opt/peardock/peardock-server</code></li>
|
||||||
<li>Creates system user <code>peardock</code> and adds it to the <strong>docker</strong> group (for <code>/var/run/docker.sock</code>)</li>
|
<li>Creates system user <code>peardock</code> and adds it to the <strong>docker</strong> group (for <code>/var/run/docker.sock</code>)</li>
|
||||||
<li>Writes <code>/etc/systemd/system/peardock.service</code> and optionally enables it</li>
|
<li>Writes <code>/etc/systemd/system/peardock.service</code> and optionally enables it</li>
|
||||||
<li>Creates <code>/opt/peardock/.env</code> (mode 600) — <strong>check this file after first start for your keys</strong></li>
|
<li>Creates <code>/opt/peardock/.env</code> (mode 600). <strong>Check this file after first start for your keys.</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
<pre><code>sudo systemctl status peardock
|
<pre><code>sudo systemctl status peardock
|
||||||
sudo journalctl -u peardock -f
|
sudo journalctl -u peardock -f
|
||||||
|
|
||||||
# After first start — keys are in .env (preferred)
|
# After first start, keys are in .env (preferred)
|
||||||
sudo grep -E '^(SERVER_PUBLIC_KEY|SERVER_SEED)=' /opt/peardock/.env
|
sudo grep -E '^(SERVER_PUBLIC_KEY|SERVER_SEED)=' /opt/peardock/.env
|
||||||
|
|
||||||
# SERVER_PUBLIC_KEY → share for viewer / paste in client
|
# SERVER_PUBLIC_KEY → share for viewer / paste in client
|
||||||
|
|||||||
+524
-47
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>PearDock · Decentralized Docker management</title>
|
<title>PearDock · P2P Docker ops without open ports</title>
|
||||||
<meta name="description" content="PearDock is peer-to-peer Docker management on HyperDHT. Never open ports, expose Docker, or wrestle with remote access again. Noise-encrypted RPC, desktop client, and Holesail tunnels." />
|
<meta name="description" content="PearDock is peer-to-peer Docker management on HyperDHT. Use a desktop console for multi-peer fleets, the job tray, terminals, stacks, and Holesail tunnels. No open ports. No exposed Docker." />
|
||||||
<meta name="keywords" content="PearDock, Docker, HyperDHT, P2P, Holesail, peer-to-peer, decentralized Docker, HoneyPeer" />
|
<meta name="keywords" content="PearDock, Docker, HyperDHT, P2P, Holesail, peer-to-peer, decentralized Docker, HoneyPeer" />
|
||||||
<meta name="author" content="HoneyPeer, LLC" />
|
<meta name="author" content="HoneyPeer, LLC" />
|
||||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
<meta property="og:site_name" content="PearDock" />
|
<meta property="og:site_name" content="PearDock" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta property="og:url" content="https://peardock.boats/" />
|
<meta property="og:url" content="https://peardock.boats/" />
|
||||||
<meta property="og:title" content="PearDock · Decentralized Docker management" />
|
<meta property="og:title" content="PearDock · P2P Docker ops without open ports" />
|
||||||
<meta property="og:description" content="PearDock is peer-to-peer Docker management on HyperDHT. Never open ports, expose Docker, or wrestle with remote access again. Noise-encrypted RPC, desktop client, and Holesail tunnels." />
|
<meta property="og:description" content="PearDock is peer-to-peer Docker management on HyperDHT. Use a desktop console for multi-peer fleets, the job tray, terminals, stacks, and Holesail tunnels. No open ports. No exposed Docker." />
|
||||||
<meta property="og:image" content="https://peardock.boats/assets/brand/og-image.png" />
|
<meta property="og:image" content="https://peardock.boats/assets/brand/og-image.png" />
|
||||||
<meta property="og:image:secure_url" content="https://peardock.boats/assets/brand/og-image.png" />
|
<meta property="og:image:secure_url" content="https://peardock.boats/assets/brand/og-image.png" />
|
||||||
<meta property="og:image:type" content="image/png" />
|
<meta property="og:image:type" content="image/png" />
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
<!-- Twitter / X -->
|
<!-- Twitter / X -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="PearDock · Decentralized Docker management" />
|
<meta name="twitter:title" content="PearDock · P2P Docker ops without open ports" />
|
||||||
<meta name="twitter:description" content="PearDock is peer-to-peer Docker management on HyperDHT. Never open ports, expose Docker, or wrestle with remote access again. Noise-encrypted RPC, desktop client, and Holesail tunnels." />
|
<meta name="twitter:description" content="PearDock is peer-to-peer Docker management on HyperDHT. Use a desktop console for multi-peer fleets, the job tray, terminals, stacks, and Holesail tunnels. No open ports. No exposed Docker." />
|
||||||
<meta name="twitter:image" content="https://peardock.boats/assets/brand/twitter-card.png" />
|
<meta name="twitter:image" content="https://peardock.boats/assets/brand/twitter-card.png" />
|
||||||
<meta name="twitter:image:alt" content="PearDock: decentralized Docker management" />
|
<meta name="twitter:image:alt" content="PearDock: decentralized Docker management" />
|
||||||
|
|
||||||
@@ -136,8 +136,10 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/home.css" />
|
<link rel="stylesheet" href="/src/styles/home.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/app-chrome.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bg-mesh" aria-hidden="true"></div>
|
<div class="bg-mesh" aria-hidden="true"></div>
|
||||||
@@ -152,47 +154,118 @@
|
|||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="hero-badge">
|
<div class="hero-badge">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
v2 · HyperDHT · AGPL-3.0
|
v2 · Live job tray · AGPL-3.0
|
||||||
</div>
|
</div>
|
||||||
<h1>
|
<h1>
|
||||||
Docker ops without<br />
|
Remote Docker that<br />
|
||||||
<span class="gradient-text">opening ports</span>
|
<span class="gradient-text">never needs a port</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Peer-to-peer Docker management on the Holepunch stack.
|
PearDock is a peer-to-peer ops console for Docker on HyperDHT.
|
||||||
Share a public key, then manage containers, stacks, and tunnels over encrypted P2P.
|
Connect with a public key, then run containers, stacks, terminals, and tunnels over Noise-encrypted RPC.<BR><BR>
|
||||||
<strong>Never open ports, expose Docker to the internet, or worry about remote access again.</strong>
|
<strong>No open ports. No VPN. No SaaS middleman.</strong>
|
||||||
No SaaS. No VPN. No inbound firewall rules.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="btn btn-primary btn-lg" href="/download">Install & download</a>
|
<a class="btn btn-primary btn-lg" href="/download">Install & download</a>
|
||||||
<a class="btn btn-secondary btn-lg" href="/learn/">How it works</a>
|
<a class="btn btn-secondary btn-lg" href="#product-tour">See live demos</a>
|
||||||
<a class="btn btn-ghost btn-lg" href="/docs/quickstart">Quick start</a>
|
<a class="btn btn-ghost btn-lg" href="/docs/quickstart">Quick start</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<div class="hero-meta-item">
|
<div class="hero-meta-item">
|
||||||
<strong>HyperDHT</strong>
|
<strong>Multi-peer fleet</strong>
|
||||||
<span>Transport</span>
|
<span>Many hosts, one client</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta-item">
|
<div class="hero-meta-item">
|
||||||
<strong>protomux-rpc</strong>
|
<strong>Job tray</strong>
|
||||||
<span>RPC protocol</span>
|
<span>Pull · deploy · prune</span>
|
||||||
</div>
|
|
||||||
<div class="hero-meta-item">
|
|
||||||
<strong>6 platforms</strong>
|
|
||||||
<span>Server + client</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta-item">
|
<div class="hero-meta-item">
|
||||||
<strong>Holesail</strong>
|
<strong>Holesail</strong>
|
||||||
<span>Port tunnels</span>
|
<span>Service tunnels</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta-item">
|
||||||
|
<strong>6 platforms</strong>
|
||||||
|
<span>Server + desktop</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pitch-points">
|
||||||
|
<div class="pitch-point">
|
||||||
|
<strong>Identity is a keypair</strong>
|
||||||
|
<span>Share a 64-hex public key. Nothing listens on a public Docker port.</span>
|
||||||
|
</div>
|
||||||
|
<div class="pitch-point">
|
||||||
|
<strong>Ops that feel local</strong>
|
||||||
|
<span>Dashboard, terminals, image updates, stacks, and registry vault in one dark UI.</span>
|
||||||
|
</div>
|
||||||
|
<div class="pitch-point">
|
||||||
|
<strong>Progress you can trust</strong>
|
||||||
|
<span>Multi-step job tray with hybrid layer pull and push. Not a spinner and a prayer.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual reveal">
|
<div class="hero-visual reveal" data-hero-mock>
|
||||||
<div class="hero-glow" aria-hidden="true"></div>
|
<div class="hero-glow" aria-hidden="true"></div>
|
||||||
<div class="hero-frame" data-lightbox="/assets/screenshots/screenshot-0.png">
|
<div class="hero-mock-stack">
|
||||||
<img src="/assets/screenshots/screenshot-0.png" alt="PearDock desktop client" width="960" height="600" />
|
<div class="hero-frame mock-frame">
|
||||||
|
<div class="mock-window" aria-hidden="true">
|
||||||
|
<div class="mock-titlebar">
|
||||||
|
<div class="mock-dots" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||||
|
<div class="mock-titlebar-label">PearDock · Dashboard</div>
|
||||||
|
<div class="mock-titlebar-meta">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
Laptop · 14 ms · ADMIN
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-body">
|
||||||
|
<aside class="mock-sidebar">
|
||||||
|
<div class="mock-nav-item is-active"><span class="ico">◈</span> Dashboard</div>
|
||||||
|
<div class="mock-nav-item"><span class="ico">▣</span> Containers</div>
|
||||||
|
<div class="mock-nav-item"><span class="ico">⧉</span> Images</div>
|
||||||
|
<div class="mock-nav-item"><span class="ico">⇄</span> Tunnels</div>
|
||||||
|
<div class="mock-nav-item"><span class="ico">⚙</span> Settings</div>
|
||||||
|
</aside>
|
||||||
|
<div class="mock-main">
|
||||||
|
<p class="mock-kicker">Overview</p>
|
||||||
|
<h3 class="mock-heading">Dashboard</h3>
|
||||||
|
<p class="mock-sub">Live engine health on the active peer</p>
|
||||||
|
<div class="mock-stat-row">
|
||||||
|
<div class="mock-stat-card is-run"><span class="lbl">Running</span><span class="val">5</span></div>
|
||||||
|
<div class="mock-stat-card"><span class="lbl">Stopped</span><span class="val">0</span></div>
|
||||||
|
<div class="mock-stat-card is-img"><span class="lbl">Images</span><span class="val">10</span></div>
|
||||||
|
<div class="mock-stat-card"><span class="lbl">Networks</span><span class="val">3</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-panels">
|
||||||
|
<div class="mock-panel">
|
||||||
|
<h4>Host load</h4>
|
||||||
|
<div class="mock-meter-row"><span>CPU</span><strong data-live-cpu>18%</strong></div>
|
||||||
|
<div class="mock-meter"><i style="width:18%" data-live-cpu-bar></i></div>
|
||||||
|
<div class="mock-meter-row"><span>Memory</span><strong data-live-mem>42%</strong></div>
|
||||||
|
<div class="mock-meter"><i style="width:42%" data-live-mem-bar></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-panel">
|
||||||
|
<h4>Live events</h4>
|
||||||
|
<div data-live-events>
|
||||||
|
<div class="mock-event-line"><span class="mock-event-time">now</span> peer online · Laptop</div>
|
||||||
|
<div class="mock-event-line"><span class="mock-event-time">now</span> docker ping · ok</div>
|
||||||
|
<div class="mock-event-line"><span class="mock-event-time">now</span> stats stream · active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-job-card" data-hero-job>
|
||||||
|
<div class="hero-job-head">
|
||||||
|
<strong>Job tray</strong>
|
||||||
|
<span class="hero-job-pill">Activity</span>
|
||||||
|
</div>
|
||||||
|
<p class="hero-job-label" data-hero-job-label>Pull nginx:latest…</p>
|
||||||
|
<div class="hero-job-track">
|
||||||
|
<div class="hero-job-bar" data-hero-job-bar></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="hero-status-bar" aria-label="Product highlights">
|
<ul class="hero-status-bar" aria-label="Product highlights">
|
||||||
<li class="hero-status-item">
|
<li class="hero-status-item">
|
||||||
@@ -225,6 +298,390 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PRODUCT DEMOS: high fidelity app chrome -->
|
||||||
|
<section class="product-demo-section" id="product-tour" data-product-demo data-active-demo="dashboard">
|
||||||
|
<div class="container-wide">
|
||||||
|
<div class="product-demo-head reveal">
|
||||||
|
<div class="section-label" style="justify-content: center;">Product tour</div>
|
||||||
|
<h2>The real console, animated</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-tabs reveal" role="tablist" aria-label="Product demos">
|
||||||
|
<button type="button" class="demo-tab is-active" role="tab" aria-selected="true" data-demo-tab="dashboard">Dashboard</button>
|
||||||
|
<button type="button" class="demo-tab" role="tab" aria-selected="false" data-demo-tab="jobs">Job tray · layers</button>
|
||||||
|
<button type="button" class="demo-tab" role="tab" aria-selected="false" data-demo-tab="containers">Containers</button>
|
||||||
|
<button type="button" class="demo-tab" role="tab" aria-selected="false" data-demo-tab="connect">Restore peers</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div class="demo-stage reveal demo-panel is-active" data-demo-panel="dashboard" role="tabpanel">
|
||||||
|
<div class="demo-stage-visual">
|
||||||
|
<div class="pd-app">
|
||||||
|
<div class="pd-app-titlebar">
|
||||||
|
<div class="pd-app-dots" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||||
|
<div class="pd-app-brand">
|
||||||
|
<img src="/assets/logo/peardock-icon-32.png" alt="" width="18" height="18" />
|
||||||
|
peardock <span>P2P Docker</span>
|
||||||
|
</div>
|
||||||
|
<div class="pd-app-titlebar-center">PearDock</div>
|
||||||
|
<div class="pd-app-health"><span class="dot"></span> Laptop · 14 ms · <span class="role">ADMIN</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-app-body">
|
||||||
|
<aside class="pd-app-side">
|
||||||
|
<div class="pd-side-label">Overview</div>
|
||||||
|
<nav class="pd-side-nav">
|
||||||
|
<div class="pd-side-link is-active"><i class="fas fa-gauge-high"></i> Dashboard</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-network-wired"></i> Fleet</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-bolt"></i> Events</div>
|
||||||
|
</nav>
|
||||||
|
<div class="pd-side-label">Workloads</div>
|
||||||
|
<nav class="pd-side-nav">
|
||||||
|
<div class="pd-side-link"><i class="fas fa-rocket"></i> Deploy</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-cube"></i> Containers</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-layer-group"></i> Images</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-diagram-project"></i> Networks</div>
|
||||||
|
</nav>
|
||||||
|
<div class="pd-side-peer"><strong>Laptop</strong>online · admin</div>
|
||||||
|
<button type="button" class="pd-side-add"><i class="fas fa-plus"></i> Add peer</button>
|
||||||
|
</aside>
|
||||||
|
<div class="pd-app-main">
|
||||||
|
<div class="dashboard-layout dash">
|
||||||
|
<header class="dash-header">
|
||||||
|
<div>
|
||||||
|
<p class="dash-kicker">Overview</p>
|
||||||
|
<h2 class="dash-title">Dashboard</h2>
|
||||||
|
<p class="dash-subtitle">Live engine health, storage, and activity on the active peer</p>
|
||||||
|
</div>
|
||||||
|
<div class="dash-header-actions">
|
||||||
|
<span class="dash-icon-btn"><i class="fas fa-rotate"></i></span>
|
||||||
|
<span class="dash-chip-btn"><i class="fas fa-rocket"></i><span>Deploy</span></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="dash-kpis">
|
||||||
|
<div class="dash-kpi dash-kpi--ok">
|
||||||
|
<div class="dash-kpi-icon"><i class="fas fa-play"></i></div>
|
||||||
|
<div class="dash-kpi-meta"><span class="dash-kpi-label">Running</span><span class="dash-kpi-value" data-kpi="running">5</span></div>
|
||||||
|
<div class="dash-kpi-glow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-kpi dash-kpi--stop">
|
||||||
|
<div class="dash-kpi-icon"><i class="fas fa-stop"></i></div>
|
||||||
|
<div class="dash-kpi-meta"><span class="dash-kpi-label">Stopped</span><span class="dash-kpi-value">0</span></div>
|
||||||
|
<div class="dash-kpi-glow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-kpi dash-kpi--info">
|
||||||
|
<div class="dash-kpi-icon"><i class="fas fa-layer-group"></i></div>
|
||||||
|
<div class="dash-kpi-meta"><span class="dash-kpi-label">Images</span><span class="dash-kpi-value" data-kpi="images">10</span></div>
|
||||||
|
<div class="dash-kpi-glow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-kpi dash-kpi--teal">
|
||||||
|
<div class="dash-kpi-icon"><i class="fas fa-diagram-project"></i></div>
|
||||||
|
<div class="dash-kpi-meta"><span class="dash-kpi-label">Networks</span><span class="dash-kpi-value" data-kpi="networks">3</span></div>
|
||||||
|
<div class="dash-kpi-glow"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="dash-panels">
|
||||||
|
<article class="dash-card">
|
||||||
|
<div class="dash-card-head"><div class="dash-card-title"><i class="fas fa-gears"></i><span>Engine</span></div></div>
|
||||||
|
<div class="dash-card-body">
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Version</span><span class="dash-metric-value">27.0.2</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Containers</span><span class="dash-metric-value">5</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Running</span><span class="dash-metric-value">5</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Images</span><span class="dash-metric-value">10</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Storage driver</span><span class="dash-metric-value">overlay2</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dash-card">
|
||||||
|
<div class="dash-card-head"><div class="dash-card-title"><i class="fas fa-microchip"></i><span>Host</span></div></div>
|
||||||
|
<div class="dash-card-body">
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Memory</span><span class="dash-metric-value">10.74 GB</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">CPU cores</span><span class="dash-metric-value">8</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Operating system</span><span class="dash-metric-value">Ubuntu 26.04 LTS</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Architecture</span><span class="dash-metric-value">x86_64</span></div>
|
||||||
|
<div class="dash-metric"><span class="dash-metric-label">Kernel</span><span class="dash-metric-value">7.0.0-27-generic</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dash-card">
|
||||||
|
<div class="dash-card-head"><div class="dash-card-title"><i class="fas fa-hard-drive"></i><span>Disk</span></div></div>
|
||||||
|
<div class="dash-card-body">
|
||||||
|
<div class="dash-disk-row"><div class="dash-disk-top"><span>Image layers</span><strong>3.57 GB</strong></div><div class="dash-disk-bar"><i data-disk-bar data-base="78" style="width:78%"></i></div></div>
|
||||||
|
<div class="dash-disk-row"><div class="dash-disk-top"><span>Images (10)</span><strong>3.57 GB</strong></div><div class="dash-disk-bar"><i data-disk-bar data-base="78" style="width:78%"></i></div></div>
|
||||||
|
<div class="dash-disk-row"><div class="dash-disk-top"><span>Containers (5)</span><strong>50.85 MB</strong></div><div class="dash-disk-bar"><i data-disk-bar data-base="12" style="width:12%"></i></div></div>
|
||||||
|
<div class="dash-disk-row"><div class="dash-disk-top"><span>Volumes (16)</span><strong>30.95 MB</strong></div><div class="dash-disk-bar"><i data-disk-bar data-base="8" style="width:8%"></i></div></div>
|
||||||
|
<div class="dash-disk-row"><div class="dash-disk-top"><span>Build cache</span><strong>0 B</strong></div><div class="dash-disk-bar"><i data-disk-bar data-base="2" style="width:2%"></i></div></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dash-card">
|
||||||
|
<div class="dash-card-head"><div class="dash-card-title"><i class="fas fa-bolt"></i><span>Live events</span></div></div>
|
||||||
|
<div class="dash-card-body" data-dash-events>
|
||||||
|
<div class="event-row"><span class="event-time">04:22:09</span><span class="event-action">start</span><span class="event-target">container vpn</span></div>
|
||||||
|
<div class="event-row"><span class="event-time">04:22:09</span><span class="event-action">exec</span><span class="event-target">healthcheck vpn</span></div>
|
||||||
|
<div class="event-row"><span class="event-time">04:22:04</span><span class="event-action">die</span><span class="event-target">container vpn</span></div>
|
||||||
|
<div class="event-row"><span class="event-time">04:21:59</span><span class="event-action">create</span><span class="event-target">container api</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<footer class="dash-actions">
|
||||||
|
<div class="dash-actions-label"><i class="fas fa-bolt"></i> Quick actions</div>
|
||||||
|
<span class="dash-action"><i class="fas fa-cube"></i> Containers</span>
|
||||||
|
<span class="dash-action"><i class="fas fa-layer-group"></i> Images</span>
|
||||||
|
<span class="dash-action dash-action--warn"><i class="fas fa-broom"></i> Prune images</span>
|
||||||
|
<span class="dash-action dash-action--danger"><i class="fas fa-trash"></i> System prune</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-stage-copy">
|
||||||
|
<h3>Dashboard that stays live</h3>
|
||||||
|
<p>KPI strip, Engine, Host, Disk, live Docker events, and quick actions. This is the same shell operators use after connect. Counts and events update continuously in this demo.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Connection health: latency, Docker version, role</li>
|
||||||
|
<li>Disk bars from <code>systemDf</code> style metrics</li>
|
||||||
|
<li>Event timeline streaming from the active peer</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JOB TRAY -->
|
||||||
|
<div class="demo-stage reveal demo-panel" data-demo-panel="jobs" role="tabpanel" hidden>
|
||||||
|
<div class="demo-stage-visual">
|
||||||
|
<div class="pd-app">
|
||||||
|
<div class="pd-app-titlebar">
|
||||||
|
<div class="pd-app-dots" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||||
|
<div class="pd-app-brand"><img src="/assets/logo/peardock-icon-32.png" alt="" width="18" height="18" /> peardock</div>
|
||||||
|
<div class="pd-app-titlebar-center">Activity · job tray</div>
|
||||||
|
<div class="pd-app-health"><span class="dot"></span> Hybrid pull</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-job-stage">
|
||||||
|
<div class="job-panel job-panel--running" data-job-tray-demo data-job-id="demo-pull">
|
||||||
|
<div class="job-panel-header">
|
||||||
|
<div class="job-panel-title">
|
||||||
|
<span class="job-kind-icon"><i class="fas fa-download"></i></span>
|
||||||
|
<div class="job-panel-title-text">
|
||||||
|
<strong>Pull image</strong>
|
||||||
|
<div class="job-panel-subtitle">nginx:latest</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-panel-header-right">
|
||||||
|
<span class="job-auto-dismiss-hint" hidden>Closing in 3s…</span>
|
||||||
|
<span class="job-steps-chip">1/4</span>
|
||||||
|
<span class="badge bg-primary job-status-badge">running</span>
|
||||||
|
<button type="button" class="job-dismiss-btn" tabindex="-1" aria-hidden="true"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-steps">
|
||||||
|
<div class="job-step job-step--active" data-job-step>
|
||||||
|
<span class="job-step-icon"><span class="job-spinner"><span class="job-spinner-ring"></span></span></span>
|
||||||
|
<span class="job-step-label">Resolve & authenticate</span>
|
||||||
|
<span class="job-step-detail text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<div class="job-step job-step--pending" data-job-step>
|
||||||
|
<span class="job-step-icon"><i class="fas fa-circle text-muted"></i></span>
|
||||||
|
<span class="job-step-label">Pull layers (hybrid progress)</span>
|
||||||
|
<span class="job-step-detail text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<div class="job-step job-step--pending" data-job-step>
|
||||||
|
<span class="job-step-icon"><i class="fas fa-circle text-muted"></i></span>
|
||||||
|
<span class="job-step-label">Create container</span>
|
||||||
|
<span class="job-step-detail text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<div class="job-step job-step--pending" data-job-step>
|
||||||
|
<span class="job-step-icon"><i class="fas fa-circle text-muted"></i></span>
|
||||||
|
<span class="job-step-label">Start & publish ports</span>
|
||||||
|
<span class="job-step-detail text-muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pull-progress pull-progress--resolving" data-pull-progress data-pull-phase="resolving">
|
||||||
|
<div class="pull-progress-summary">
|
||||||
|
<span class="pull-progress-phase">Resolving</span>
|
||||||
|
<span class="pull-progress-meta">nginx:latest</span>
|
||||||
|
<span class="pull-progress-pct">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="pull-progress-bar"><div class="pull-progress-bar-fill" style="width:8%"></div></div>
|
||||||
|
<div class="pull-progress-track" aria-hidden="true">
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
<span class="pull-layer-pip pull-layer-pip--idle"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pull-progress-layers" aria-label="Active layers">
|
||||||
|
<div class="pull-layer pull-layer--empty"><span class="pull-layer-id">…</span><div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div><span class="pull-layer-meta"></span></div>
|
||||||
|
<div class="pull-layer pull-layer--empty"><span class="pull-layer-id">…</span><div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div><span class="pull-layer-meta"></span></div>
|
||||||
|
<div class="pull-layer pull-layer--empty"><span class="pull-layer-id">…</span><div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div><span class="pull-layer-meta"></span></div>
|
||||||
|
<div class="pull-layer pull-layer--empty"><span class="pull-layer-id">…</span><div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div><span class="pull-layer-meta"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pull-progress-waiting pull-progress-waiting--empty"> </div>
|
||||||
|
<div class="pull-progress-activity">Resolving image nginx:latest…</div>
|
||||||
|
</div>
|
||||||
|
<details class="job-log-details" open>
|
||||||
|
<summary class="job-log-summary"><span class="job-log-summary-label">Live output</span></summary>
|
||||||
|
<div class="job-log" role="log"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-stage-copy">
|
||||||
|
<h3>Hybrid pull, real layer UI</h3>
|
||||||
|
<p>
|
||||||
|
The same job panel as the app. You get a step timeline, overall bar, layer pip track, and four fixed layer slots with per layer download and extract bars. Waiting count and activity line follow. Create and start run after the pull.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Per-layer MB progress (download → extract)</li>
|
||||||
|
<li>Pip track colors: wait / download / extract / done</li>
|
||||||
|
<li>Stable height while pulling (no layout jump)</li>
|
||||||
|
<li>Live log lines under the progress card</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTAINERS -->
|
||||||
|
<div class="demo-stage reveal demo-panel" data-demo-panel="containers" role="tabpanel" hidden>
|
||||||
|
<div class="demo-stage-visual">
|
||||||
|
<div class="pd-app">
|
||||||
|
<div class="pd-app-titlebar">
|
||||||
|
<div class="pd-app-dots" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||||
|
<div class="pd-app-brand"><img src="/assets/logo/peardock-icon-32.png" alt="" width="18" height="18" /> peardock</div>
|
||||||
|
<div class="pd-app-titlebar-center">Containers</div>
|
||||||
|
<div class="pd-app-health"><span class="dot"></span> Image updates</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-app-body">
|
||||||
|
<aside class="pd-app-side">
|
||||||
|
<div class="pd-side-label">Workloads</div>
|
||||||
|
<nav class="pd-side-nav">
|
||||||
|
<div class="pd-side-link"><i class="fas fa-gauge-high"></i> Dashboard</div>
|
||||||
|
<div class="pd-side-link is-active"><i class="fas fa-cube"></i> Containers</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-layer-group"></i> Images</div>
|
||||||
|
<div class="pd-side-link"><i class="fas fa-hard-drive"></i> Volumes</div>
|
||||||
|
</nav>
|
||||||
|
<div class="pd-side-peer"><strong>Laptop</strong>5 running</div>
|
||||||
|
<button type="button" class="pd-side-add"><i class="fas fa-plus"></i> Add peer</button>
|
||||||
|
</aside>
|
||||||
|
<div class="pd-app-main">
|
||||||
|
<div class="pd-containers-wrap">
|
||||||
|
<div class="pd-containers-toolbar">
|
||||||
|
<h3><i class="fas fa-cube"></i> Containers</h3>
|
||||||
|
<div class="pd-filter-seg">
|
||||||
|
<button type="button" class="is-active">All</button>
|
||||||
|
<button type="button">Running</button>
|
||||||
|
<button type="button">Exited</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="pd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Ports</th>
|
||||||
|
<th>Updates</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="name">vpn</td>
|
||||||
|
<td><span class="pd-badge pd-badge--running">running</span></td>
|
||||||
|
<td class="mono">gluetun:latest</td>
|
||||||
|
<td class="mono">none</td>
|
||||||
|
<td><span class="pd-update pd-update--ok" title="Up to date"><i class="fas fa-check"></i></span></td>
|
||||||
|
<td><div class="pd-row-actions"><span><i class="fas fa-terminal"></i></span><span><i class="fas fa-file-lines"></i></span><span><i class="fas fa-ellipsis"></i></span></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="name">apache-httpd</td>
|
||||||
|
<td><span class="pd-badge pd-badge--running">running</span></td>
|
||||||
|
<td class="mono">httpd:latest</td>
|
||||||
|
<td class="mono">8080→80</td>
|
||||||
|
<td><span class="pd-update pd-update--avail" title="Update available"><i class="fas fa-arrow-up"></i></span></td>
|
||||||
|
<td><div class="pd-row-actions"><span><i class="fas fa-terminal"></i></span><span><i class="fas fa-file-lines"></i></span><span><i class="fas fa-ellipsis"></i></span></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="name">api</td>
|
||||||
|
<td><span class="pd-badge pd-badge--running">running</span></td>
|
||||||
|
<td class="mono">ghcr.io/app:1.4</td>
|
||||||
|
<td class="mono">3000→3000</td>
|
||||||
|
<td><span class="pd-update pd-update--ok"><i class="fas fa-check"></i></span></td>
|
||||||
|
<td><div class="pd-row-actions"><span><i class="fas fa-terminal"></i></span><span><i class="fas fa-file-lines"></i></span><span><i class="fas fa-ellipsis"></i></span></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="name">worker</td>
|
||||||
|
<td><span class="pd-badge pd-badge--exited">exited</span></td>
|
||||||
|
<td class="mono">node:20</td>
|
||||||
|
<td class="mono">none</td>
|
||||||
|
<td><span class="pd-update pd-update--avail" title="Update available"><i class="fas fa-arrow-up"></i></span></td>
|
||||||
|
<td><div class="pd-row-actions"><span><i class="fas fa-play"></i></span><span><i class="fas fa-file-lines"></i></span><span><i class="fas fa-ellipsis"></i></span></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="name">redis</td>
|
||||||
|
<td><span class="pd-badge pd-badge--running">running</span></td>
|
||||||
|
<td class="mono">redis:7.0.7</td>
|
||||||
|
<td class="mono">6379→6379</td>
|
||||||
|
<td><span class="pd-update pd-update--ok"><i class="fas fa-check"></i></span></td>
|
||||||
|
<td><div class="pd-row-actions"><span><i class="fas fa-terminal"></i></span><span><i class="fas fa-file-lines"></i></span><span><i class="fas fa-ellipsis"></i></span></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-stage-copy">
|
||||||
|
<h3>Containers with update digests</h3>
|
||||||
|
<p>State badges, published ports, and the Updates column (✓ up to date, ↑ pull available). Matches the real list, with terminal and logs actions per row.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Registry digest compare for each tag</li>
|
||||||
|
<li>Click ↑ to pull via the job tray</li>
|
||||||
|
<li>Bulk actions and role-gated remove</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONNECT / RESTORE -->
|
||||||
|
<div class="demo-stage reveal demo-panel" data-demo-panel="connect" role="tabpanel" hidden>
|
||||||
|
<div class="demo-stage-visual">
|
||||||
|
<div class="pd-app">
|
||||||
|
<div class="pd-app-titlebar">
|
||||||
|
<div class="pd-app-dots" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||||
|
<div class="pd-app-brand"><img src="/assets/logo/peardock-icon-32.png" alt="" width="18" height="18" /> peardock</div>
|
||||||
|
<div class="pd-app-titlebar-center">Restoring connections</div>
|
||||||
|
<div class="pd-app-health"><span class="dot"></span> Boot</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-connect-stage">
|
||||||
|
<div class="pd-connect-card">
|
||||||
|
<div class="orb">
|
||||||
|
<span class="job-spinner job-spinner--xl"><span class="job-spinner-ring"></span></span>
|
||||||
|
</div>
|
||||||
|
<h3>Restoring connections</h3>
|
||||||
|
<p class="lead">Reconnecting to your saved servers over HyperDHT…</p>
|
||||||
|
<div class="pd-connect-status">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span data-connect-status>Dialing HyperDHT…</span>
|
||||||
|
</div>
|
||||||
|
<div class="pd-connect-progress">
|
||||||
|
<div class="track"><div class="bar" data-connect-bar style="width:15%"></div></div>
|
||||||
|
<div class="meta"><strong data-connect-count>0 of 2</strong><span>peers</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-stage-copy">
|
||||||
|
<h3>Boot restore, not a blank splash</h3>
|
||||||
|
<p>On launch the client re-dials saved peers with live status and progress. It lands on the dashboard when a peer is online.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Last-active peer preferred</li>
|
||||||
|
<li>Auto-reconnect every 5s if the link drops</li>
|
||||||
|
<li>pd1 invites for operator access without sharing the seed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- FEATURES -->
|
<!-- FEATURES -->
|
||||||
<section class="section" id="features">
|
<section class="section" id="features">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -232,9 +689,9 @@
|
|||||||
<div class="section-label">Features</div>
|
<div class="section-label">Features</div>
|
||||||
<h2>Full Engine coverage.<br />Zero ports to open.</h2>
|
<h2>Full Engine coverage.<br />Zero ports to open.</h2>
|
||||||
<p class="lead mt-4">
|
<p class="lead mt-4">
|
||||||
Containers, images, volumes, networks, stacks, Swarm, terminals, logs, GitOps,
|
Containers, images, volumes, networks, stacks, Swarm, terminals, logs, and GitOps.
|
||||||
registry browser + vault, image update checks, Portainer-style templates,
|
Registry browser and vault. Image update checks. Portainer-style templates. Roles and Holesail tunnels.
|
||||||
roles, and Holesail tunnels in a desktop console built for real ops.
|
All in a desktop console built for real ops.
|
||||||
Remote Docker without port forwards, public <code>dockerd</code>, or a permanent VPN.
|
Remote Docker without port forwards, public <code>dockerd</code>, or a permanent VPN.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,17 +700,17 @@
|
|||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">⬡</div>
|
<div class="card-icon">⬡</div>
|
||||||
<h3>No ports. No VPN.</h3>
|
<h3>No ports. No VPN.</h3>
|
||||||
<p>Server identity is a HyperDHT keypair. Share the public key; peers holepunch. Nothing phones home, nothing listens on a public Docker port.</p>
|
<p>Server identity is a HyperDHT keypair. Share the public key. Peers holepunch. Nothing phones home. Nothing listens on a public Docker port.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">🔐</div>
|
<div class="card-icon">🔐</div>
|
||||||
<h3>Noise encryption</h3>
|
<h3>Noise encryption</h3>
|
||||||
<p>Every control connection is end-to-end encrypted. Roles (viewer, operator, admin), allowlists, pd1 invites, and audit logging help multi-operator fleets stay safe.</p>
|
<p>Every control connection is end-to-end encrypted. Roles cover viewer, operator, and admin. Allowlists, pd1 invites, and audit logging help multi-operator fleets stay safe.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">🚢</div>
|
<div class="card-icon">🚢</div>
|
||||||
<h3>Docker ops console</h3>
|
<h3>Docker ops console</h3>
|
||||||
<p>Deploy templates, add containers, recreate, prune, stack env files, image update indicators, secrets/configs, and schedules. Built for real operators.</p>
|
<p>Deploy templates. Add containers. Recreate and prune. Stack env files, image update indicators, secrets, configs, and schedules. Built for real operators.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">⇄</div>
|
<div class="card-icon">⇄</div>
|
||||||
@@ -263,7 +720,7 @@
|
|||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">🖥️</div>
|
<div class="card-icon">🖥️</div>
|
||||||
<h3>Desktop client</h3>
|
<h3>Desktop client</h3>
|
||||||
<p>Electron (or Pear) with multi-peer fleet, multi-step job tray and hybrid pull progress, live stats, terminals, and OTA-ready packaging.</p>
|
<p>Electron or Pear. Multi-peer fleet. Multi-step job tray with hybrid pull progress. Live stats, terminals, and OTA-ready packaging.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card reveal">
|
<article class="card reveal">
|
||||||
<div class="card-icon">📦</div>
|
<div class="card-icon">📦</div>
|
||||||
@@ -281,18 +738,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ARCHITECTURE -->
|
<!-- ARCHITECTURE + HOW IT WORKS -->
|
||||||
<section class="section arch-section" id="architecture">
|
<section class="section arch-section" id="architecture">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="reveal text-center" style="max-width: 640px; margin: 0 auto;">
|
<div class="reveal text-center" style="max-width: 640px; margin: 0 auto;">
|
||||||
<div class="section-label" style="justify-content: center;">Architecture</div>
|
<div class="section-label" style="justify-content: center;">How it works</div>
|
||||||
<h2>Two planes. Still zero open ports.</h2>
|
<h2>Keys, peers, Docker.<br />Still zero open ports.</h2>
|
||||||
<p class="lead" style="margin: 1rem auto 0;">
|
<p class="lead" style="margin: 1rem auto 0;">
|
||||||
Control traffic uses HyperDHT and protomux-rpc. Service access uses Holesail.
|
Control traffic uses HyperDHT and protomux-rpc. Service access uses Holesail.
|
||||||
Both are peer-to-peer. No inbound firewall rules. No public Docker API.
|
Both are peer-to-peer.
|
||||||
|
No inbound firewall rules. No public Docker API.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="how-flow reveal" data-how-flow>
|
||||||
|
<article class="how-step is-active" data-how-step>
|
||||||
|
<div class="how-step-num">1</div>
|
||||||
|
<h3>Run the server</h3>
|
||||||
|
<p>On a host with Docker, install via one-liner or binary. Identity is a HyperDHT keypair printed at start: <code>SERVER_PUBLIC_KEY</code> and seed.</p>
|
||||||
|
</article>
|
||||||
|
<article class="how-step" data-how-step>
|
||||||
|
<div class="how-step-num">2</div>
|
||||||
|
<h3>Launch the client</h3>
|
||||||
|
<p>Electron desktop (or Pear). Paste a public key, key + seed, or a <code>pd1.</code> invite. Multi-peer fleet lives in the sidebar.</p>
|
||||||
|
</article>
|
||||||
|
<article class="how-step" data-how-step>
|
||||||
|
<div class="how-step-num">3</div>
|
||||||
|
<h3>Operate</h3>
|
||||||
|
<p>Deploy, terminal, logs, stacks, registry, tunnels. Long work streams through the job tray with real progress.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="arch-diagram reveal">
|
<div class="arch-diagram reveal">
|
||||||
<div class="arch-node">
|
<div class="arch-node">
|
||||||
<div class="node-icon">⌘</div>
|
<div class="node-icon">⌘</div>
|
||||||
@@ -322,7 +798,7 @@
|
|||||||
<div class="arch-details reveal">
|
<div class="arch-details reveal">
|
||||||
<div class="arch-detail">
|
<div class="arch-detail">
|
||||||
<h4>Control plane</h4>
|
<h4>Control plane</h4>
|
||||||
<p>Handshake, containers (including image update checks), deploy stacks and templates, logs, stats pushes, terminal streams, registry vault + remote browser, and peer policy. Protocol version is negotiated on connect.</p>
|
<p>Handshake, containers (including image update checks), deploy stacks and templates, logs, and stats pushes. Terminal streams, registry vault, remote browser, and peer policy. Protocol version is negotiated on connect.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="arch-detail">
|
<div class="arch-detail">
|
||||||
<h4>Data / tunnels</h4>
|
<h4>Data / tunnels</h4>
|
||||||
@@ -343,8 +819,8 @@
|
|||||||
<div class="feature-row reveal">
|
<div class="feature-row reveal">
|
||||||
<div class="feature-copy">
|
<div class="feature-copy">
|
||||||
<div class="section-label">Fleet</div>
|
<div class="section-label">Fleet</div>
|
||||||
<h2>Multi-peer. Multi-host. One client.</h2>
|
<h2>Multi-peer. Multi-host.<br />One client.</h2>
|
||||||
<p class="lead">Connect to many servers at once. Tag environments (prod / staging / lab). Switch contexts without losing job history or health signals. Manage saved hosts under Settings → Peers; server ACL under Access.</p>
|
<p class="lead">Connect to many servers at once. Tag environments (prod / staging / lab). Switch contexts without losing job history or health signals. Manage saved hosts under Settings → Peers. Server ACL lives under Access.</p>
|
||||||
<ul class="feature-list">
|
<ul class="feature-list">
|
||||||
<li>Peer disk cache under <code>~/.config/peardock</code></li>
|
<li>Peer disk cache under <code>~/.config/peardock</code></li>
|
||||||
<li>Connection health badge: latency, Docker status, role</li>
|
<li>Connection health badge: latency, Docker status, role</li>
|
||||||
@@ -361,7 +837,7 @@
|
|||||||
<div class="feature-copy">
|
<div class="feature-copy">
|
||||||
<div class="section-label">Operations</div>
|
<div class="section-label">Operations</div>
|
||||||
<h2>Deploy like you mean it.</h2>
|
<h2>Deploy like you mean it.</h2>
|
||||||
<p class="lead">Portainer-style templates and blank Add container. Compose stacks with env files and overrides. Image update indicators. System prune. Volume file browser. Multi-step job tray with hybrid pull progress.</p>
|
<p class="lead">Portainer-style templates and blank Add container. Compose stacks with env files and overrides. Image update indicators, system prune, and volume file browser. Multi-step job tray with hybrid pull progress. <a href="#product-tour">See the demo</a>.</p>
|
||||||
<ul class="feature-list">
|
<ul class="feature-list">
|
||||||
<li>Container and stack templates; always-pull on create / duplicate</li>
|
<li>Container and stack templates; always-pull on create / duplicate</li>
|
||||||
<li>GitOps-friendly stack deploy; remove cleans project networks</li>
|
<li>GitOps-friendly stack deploy; remove cleans project networks</li>
|
||||||
@@ -377,16 +853,16 @@
|
|||||||
<div class="feature-row reveal">
|
<div class="feature-row reveal">
|
||||||
<div class="feature-copy">
|
<div class="feature-copy">
|
||||||
<div class="section-label">Tunnels</div>
|
<div class="section-label">Tunnels</div>
|
||||||
<h2>Share a service, never open a port.</h2>
|
<h2>Share a service.<br />Never open a port.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Create a Holesail tunnel from a container or host port. Copy the <code>hs://</code> URL.
|
Create a Holesail tunnel from a container or host port. Copy the <code>hs://</code> URL.
|
||||||
Remote peers bind locally. You never punch a firewall hole or expose Docker to the internet.
|
Remote peers bind locally. You never punch a firewall hole or expose Docker to the internet.
|
||||||
</p>
|
</p>
|
||||||
<ul class="feature-list">
|
<ul class="feature-list">
|
||||||
<li>No inbound port forwards. P2P hole punching instead.</li>
|
<li>No inbound port forwards. P2P hole punching instead.</li>
|
||||||
<li>Secure mode by default (capability ≠ raw listen key)</li>
|
<li>Secure mode by default (capability is not a raw listen key)</li>
|
||||||
<li>SSRF guard: loopback / allowlisted hosts only</li>
|
<li>SSRF guard: loopback and allowlisted hosts only</li>
|
||||||
<li>Max concurrent tunnels · audited create/close</li>
|
<li>Max concurrent tunnels. Audited create and close.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="btn btn-ghost mt-4" href="docs/holesail.html">Holesail guide →</a>
|
<a class="btn btn-ghost mt-4" href="docs/holesail.html">Holesail guide →</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,8 +908,8 @@
|
|||||||
<div class="section-label">Quick start</div>
|
<div class="section-label">Quick start</div>
|
||||||
<h2>Install in one line. Connect in three steps.</h2>
|
<h2>Install in one line. Connect in three steps.</h2>
|
||||||
<p class="lead mt-4">
|
<p class="lead mt-4">
|
||||||
No public IP gymnastics, no open ports, no VPN checklist.
|
No public IP gymnastics. No open ports. No VPN checklist.
|
||||||
Interactive installer pulls rolling binaries, wires Linux systemd + docker group for the server, or installs a desktop client.
|
The interactive installer pulls rolling binaries. It wires Linux systemd and the docker group for the server, or installs a desktop client.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card reveal install-card">
|
<div class="card reveal install-card">
|
||||||
@@ -488,5 +964,6 @@ curl -fsSL https://install.peardock.boats | bash -s -- --client --yes</code></pr
|
|||||||
<div data-footer data-prefix=""></div>
|
<div data-footer data-prefix=""></div>
|
||||||
|
|
||||||
<script type="module" src="/src/js/site.js"></script>
|
<script type="module" src="/src/js/site.js"></script>
|
||||||
|
<script type="module" src="/src/js/home-demos.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -540,7 +540,7 @@ flowchart TB
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Public key vs SERVER_SEED</summary>
|
<summary>Public key vs SERVER_SEED</summary>
|
||||||
<p>Both are written to <code>/opt/peardock/.env</code> on first start. The public key is what clients dial (viewer if used alone). The seed is secret server identity — paste it with the public key for admin only. Sharing the seed is equivalent to handing over admin.</p>
|
<p>Both are written to <code>/opt/peardock/.env</code> on first start. The public key is what clients dial (viewer if used alone). The seed is secret server identity. Paste it with the public key for admin only. Sharing the seed is equivalent to handing over admin.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>pd1. invite</summary>
|
<summary>pd1. invite</summary>
|
||||||
|
|||||||
+3
-3
@@ -255,7 +255,7 @@ function sitemapChangefreq(urlPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Google Search–compatible sitemap 0.9 document from dist HTML pages.
|
* Build a Google Search-compatible sitemap 0.9 document from dist HTML pages.
|
||||||
* Strict sitemaps.org protocol only (loc, lastmod, changefreq, priority).
|
* Strict sitemaps.org protocol only (loc, lastmod, changefreq, priority).
|
||||||
* @see https://www.sitemaps.org/protocol.html
|
* @see https://www.sitemaps.org/protocol.html
|
||||||
* @see https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
|
* @see https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
|
||||||
@@ -275,7 +275,7 @@ function buildSitemapXml() {
|
|||||||
const existing = byPath.get(urlPath)
|
const existing = byPath.get(urlPath)
|
||||||
if (existing && existing.lastmod >= lastmod) continue
|
if (existing && existing.lastmod >= lastmod) continue
|
||||||
|
|
||||||
// Absolute HTTPS loc only — required by the protocol
|
// Absolute HTTPS loc only · required by the protocol
|
||||||
const loc = SITE_ORIGIN + (urlPath === '/' ? '/' : urlPath)
|
const loc = SITE_ORIGIN + (urlPath === '/' ? '/' : urlPath)
|
||||||
byPath.set(urlPath, {
|
byPath.set(urlPath, {
|
||||||
loc,
|
loc,
|
||||||
@@ -291,7 +291,7 @@ function buildSitemapXml() {
|
|||||||
return a.loc.localeCompare(b.loc)
|
return a.loc.localeCompare(b.loc)
|
||||||
})
|
})
|
||||||
|
|
||||||
// One declaration + default namespace only (no xhtml extras — single-language site)
|
// One declaration + default namespace only (no xhtml extras · single-language site)
|
||||||
const parts = [
|
const parts = [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
|||||||
@@ -0,0 +1,869 @@
|
|||||||
|
/**
|
||||||
|
* High-fidelity PearDock product demos.
|
||||||
|
* DOM + class names match the desktop client (job tray, pull layers, dashboard).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const reduced = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||||
|
|
||||||
|
function $(sel, root = document) {
|
||||||
|
return root.querySelector(sel)
|
||||||
|
}
|
||||||
|
function $$(sel, root = document) {
|
||||||
|
return [...root.querySelectorAll(sel)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
function initDemoTabs() {
|
||||||
|
const root = $('[data-product-demo]')
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const tabs = $$('[data-demo-tab]', root)
|
||||||
|
const panels = $$('[data-demo-panel]', root)
|
||||||
|
const timers = { jobs: null, dashboard: null, connect: null, containers: null }
|
||||||
|
|
||||||
|
const stopAll = () => {
|
||||||
|
Object.keys(timers).forEach((k) => {
|
||||||
|
if (timers[k]) {
|
||||||
|
clearTimeout(timers[k])
|
||||||
|
clearInterval(timers[k])
|
||||||
|
timers[k] = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = (name) => {
|
||||||
|
tabs.forEach((t) => {
|
||||||
|
const on = t.getAttribute('data-demo-tab') === name
|
||||||
|
t.classList.toggle('is-active', on)
|
||||||
|
t.setAttribute('aria-selected', on ? 'true' : 'false')
|
||||||
|
})
|
||||||
|
panels.forEach((p) => {
|
||||||
|
const on = p.getAttribute('data-demo-panel') === name
|
||||||
|
p.classList.toggle('is-active', on)
|
||||||
|
p.hidden = !on
|
||||||
|
})
|
||||||
|
root.setAttribute('data-active-demo', name)
|
||||||
|
stopAll()
|
||||||
|
if (name === 'dashboard') timers.dashboard = startDashboardDemo(root)
|
||||||
|
if (name === 'jobs') timers.jobs = startJobPullDemo(root)
|
||||||
|
if (name === 'connect') timers.connect = startConnectDemo(root)
|
||||||
|
if (name === 'containers') timers.containers = startContainersDemo(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
root.dataset.userPicked = '1'
|
||||||
|
activate(tab.getAttribute('data-demo-tab'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let cycle = null
|
||||||
|
const order = ['dashboard', 'jobs', 'containers', 'connect']
|
||||||
|
let idx = 0
|
||||||
|
const startCycle = () => {
|
||||||
|
if (reduced() || cycle) return
|
||||||
|
cycle = setInterval(() => {
|
||||||
|
if (root.dataset.userPicked === '1') return
|
||||||
|
if (root.matches(':hover') || root.matches(':focus-within')) return
|
||||||
|
idx = (idx + 1) % order.length
|
||||||
|
activate(order[idx])
|
||||||
|
}, 9000)
|
||||||
|
}
|
||||||
|
const stopCycle = () => {
|
||||||
|
if (cycle) {
|
||||||
|
clearInterval(cycle)
|
||||||
|
cycle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) startCycle()
|
||||||
|
else {
|
||||||
|
stopCycle()
|
||||||
|
stopAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 }
|
||||||
|
).observe(root)
|
||||||
|
} else startCycle()
|
||||||
|
|
||||||
|
activate('dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard: live KPI ticks, disk bars, event stream ── */
|
||||||
|
function startDashboardDemo(root) {
|
||||||
|
const stage = $('[data-demo-panel="dashboard"]', root)
|
||||||
|
if (!stage) return null
|
||||||
|
|
||||||
|
const running = $('[data-kpi="running"]', stage)
|
||||||
|
const images = $('[data-kpi="images"]', stage)
|
||||||
|
const networks = $('[data-kpi="networks"]', stage)
|
||||||
|
const events = $('[data-dash-events]', stage)
|
||||||
|
const kpis = $$('.dash-kpi', stage)
|
||||||
|
|
||||||
|
const eventPool = [
|
||||||
|
{ action: 'start', target: 'container vpn' },
|
||||||
|
{ action: 'exec', target: 'healthcheck vpn' },
|
||||||
|
{ action: 'die', target: 'container vpn' },
|
||||||
|
{ action: 'create', target: 'container api' },
|
||||||
|
{ action: 'pull', target: 'image redis:7' },
|
||||||
|
{ action: 'connect', target: 'network bridge' },
|
||||||
|
{ action: 'mount', target: 'volume data' },
|
||||||
|
{ action: 'restart', target: 'container httpd' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let eIdx = 0
|
||||||
|
let img = 10
|
||||||
|
let run = 5
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
// subtle KPI pulse
|
||||||
|
const kpi = kpis[Math.floor(Math.random() * kpis.length)]
|
||||||
|
if (kpi) {
|
||||||
|
kpi.classList.add('is-tick')
|
||||||
|
setTimeout(() => kpi.classList.remove('is-tick'), 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.random() > 0.55 && images) {
|
||||||
|
img = 10 + Math.floor(Math.random() * 3)
|
||||||
|
images.textContent = String(img)
|
||||||
|
}
|
||||||
|
if (Math.random() > 0.7 && running) {
|
||||||
|
run = 4 + Math.floor(Math.random() * 3)
|
||||||
|
running.textContent = String(run)
|
||||||
|
}
|
||||||
|
if (networks && Math.random() > 0.85) {
|
||||||
|
networks.textContent = String(3 + Math.floor(Math.random() * 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk bar micro-jitter
|
||||||
|
$$('[data-disk-bar]', stage).forEach((bar) => {
|
||||||
|
const base = Number(bar.dataset.base || 60)
|
||||||
|
const w = Math.max(8, Math.min(96, base + (Math.random() * 6 - 3)))
|
||||||
|
bar.style.width = `${w}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (events) {
|
||||||
|
eIdx = (eIdx + 1) % eventPool.length
|
||||||
|
const ev = eventPool[eIdx]
|
||||||
|
const now = new Date()
|
||||||
|
const t = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'event-row'
|
||||||
|
row.innerHTML = `<span class="event-time">${t}</span><span class="event-action">${ev.action}</span><span class="event-target">${ev.target}</span>`
|
||||||
|
events.prepend(row)
|
||||||
|
// Keep the card short: only a few event rows visible
|
||||||
|
while (events.children.length > 4) events.lastElementChild?.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
return setInterval(tick, reduced() ? 2000 : 1400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Job tray: parallel hybrid pull (matches real Docker layer concurrency) ── */
|
||||||
|
const LAYER_SLOTS = 4
|
||||||
|
const MAX_CONCURRENT = 3
|
||||||
|
|
||||||
|
function startJobPullDemo(root) {
|
||||||
|
const panel = $('[data-job-tray-demo]', root)
|
||||||
|
if (!panel) return null
|
||||||
|
|
||||||
|
const catalog = [
|
||||||
|
{ id: 'sha256:a1b2c3d4e5f6789012345678', shortId: 'a1b2c3d4', size: 12.4 },
|
||||||
|
{ id: 'sha256:c3d4e5f67890123456789012', shortId: 'c3d4e5f6', size: 34.1 },
|
||||||
|
{ id: 'sha256:11223344556677889900aabb', shortId: '11223344', size: 8.7 },
|
||||||
|
{ id: 'sha256:77889900aabbccddeeff0011', shortId: '77889900', size: 52.0 },
|
||||||
|
{ id: 'sha256:bbccddeeff00112233445566', shortId: 'bbccddee', size: 3.2 },
|
||||||
|
{ id: 'sha256:ffeeddccbbaa998877665544', shortId: 'ffeeddcc', size: 19.6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const frames = simulatePull(catalog)
|
||||||
|
let i = 0
|
||||||
|
let lastLog = ''
|
||||||
|
|
||||||
|
const paint = (frame) => {
|
||||||
|
panel.className = `job-panel job-panel--${frame.status}`
|
||||||
|
|
||||||
|
const badge = $('.job-status-badge', panel)
|
||||||
|
if (badge) {
|
||||||
|
badge.className = `badge bg-${frame.status === 'success' ? 'success' : 'primary'} job-status-badge`
|
||||||
|
badge.textContent = frame.status === 'success' ? 'success' : 'running'
|
||||||
|
}
|
||||||
|
const chip = $('.job-steps-chip', panel)
|
||||||
|
if (chip) chip.textContent = frame.chip
|
||||||
|
const sub = $('.job-panel-subtitle', panel)
|
||||||
|
if (sub) {
|
||||||
|
sub.textContent = frame.subtitle
|
||||||
|
sub.hidden = !frame.subtitle
|
||||||
|
}
|
||||||
|
const hint = $('.job-auto-dismiss-hint', panel)
|
||||||
|
if (hint) hint.hidden = frame.status !== 'success'
|
||||||
|
|
||||||
|
$$('[data-job-step]', panel).forEach((el, n) => {
|
||||||
|
const st = frame.steps[n] || 'pending'
|
||||||
|
el.className = `job-step job-step--${st}`
|
||||||
|
const ico = $('.job-step-icon', el)
|
||||||
|
if (ico) {
|
||||||
|
if (st === 'active') {
|
||||||
|
if (!ico.querySelector('.job-spinner')) {
|
||||||
|
ico.innerHTML =
|
||||||
|
'<span class="job-spinner" role="status"><span class="job-spinner-ring"></span></span>'
|
||||||
|
}
|
||||||
|
} else if (st === 'success') {
|
||||||
|
ico.innerHTML = '<i class="fas fa-check-circle text-success" aria-hidden="true"></i>'
|
||||||
|
} else {
|
||||||
|
ico.innerHTML = '<i class="fas fa-circle text-muted" aria-hidden="true"></i>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detail = $('.job-step-detail', el)
|
||||||
|
if (detail) {
|
||||||
|
detail.textContent = frame.stepDetails?.[n] || ''
|
||||||
|
detail.hidden = !frame.stepDetails?.[n]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pull = $('[data-pull-progress]', panel)
|
||||||
|
if (!pull) return
|
||||||
|
pull.className = `pull-progress pull-progress--${frame.phase}`
|
||||||
|
pull.dataset.pullPhase = frame.phase
|
||||||
|
|
||||||
|
const phaseEl = $('.pull-progress-phase', pull)
|
||||||
|
const metaEl = $('.pull-progress-meta', pull)
|
||||||
|
const pctEl = $('.pull-progress-pct', pull)
|
||||||
|
const fill = $('.pull-progress-bar-fill', pull)
|
||||||
|
const waiting = $('.pull-progress-waiting', pull)
|
||||||
|
const activity = $('.pull-progress-activity', pull)
|
||||||
|
|
||||||
|
if (phaseEl) phaseEl.textContent = frame.phaseLabel
|
||||||
|
if (metaEl) metaEl.textContent = frame.summary
|
||||||
|
if (pctEl) pctEl.textContent = frame.pctText
|
||||||
|
if (fill) fill.style.width = `${frame.barWidth}%`
|
||||||
|
if (waiting) {
|
||||||
|
waiting.textContent = frame.waiting || '\u00a0'
|
||||||
|
waiting.classList.toggle('pull-progress-waiting--empty', !frame.waiting)
|
||||||
|
}
|
||||||
|
if (activity) activity.textContent = frame.activity
|
||||||
|
|
||||||
|
const track = $('.pull-progress-track', pull)
|
||||||
|
if (track && frame.pips) {
|
||||||
|
// ensure pip count matches layer catalog
|
||||||
|
if (track.children.length !== frame.pips.length) {
|
||||||
|
track.innerHTML = frame.pips
|
||||||
|
.map((p) => `<span class="pull-layer-pip pull-layer-pip--${p}"></span>`)
|
||||||
|
.join('')
|
||||||
|
} else {
|
||||||
|
;[...track.children].forEach((el, idx) => {
|
||||||
|
const next = `pull-layer-pip pull-layer-pip--${frame.pips[idx]}`
|
||||||
|
if (el.className !== next) el.className = next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch 4 fixed layer slots in place (smooth bar fills, no remount)
|
||||||
|
patchLayerSlots(pull, frame.activeLayers)
|
||||||
|
|
||||||
|
const log = $('.job-log', panel)
|
||||||
|
if (log && frame.logLine && frame.logLine !== lastLog) {
|
||||||
|
lastLog = frame.logLine
|
||||||
|
const line = document.createElement('div')
|
||||||
|
line.className = 'job-log-line job-log--info'
|
||||||
|
const ts = new Date().toLocaleTimeString()
|
||||||
|
line.innerHTML = `<span class="job-log-ts">${ts}</span>${frame.logLine}`
|
||||||
|
log.appendChild(line)
|
||||||
|
while (log.children.length > 14) log.firstElementChild?.remove()
|
||||||
|
log.scrollTop = log.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const frame = frames[i]
|
||||||
|
paint(frame)
|
||||||
|
i = (i + 1) % frames.length
|
||||||
|
if (i === 0) {
|
||||||
|
lastLog = ''
|
||||||
|
const log = $('.job-log', panel)
|
||||||
|
if (log) log.innerHTML = ''
|
||||||
|
}
|
||||||
|
const delay =
|
||||||
|
frame.status === 'success' && i === 0
|
||||||
|
? reduced()
|
||||||
|
? 500
|
||||||
|
: 2600
|
||||||
|
: reduced()
|
||||||
|
? 120
|
||||||
|
: frame.phase === 'downloading' || frame.phase === 'extracting'
|
||||||
|
? 280
|
||||||
|
: 550
|
||||||
|
return setTimeout(step, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = $('.job-log', panel)
|
||||||
|
if (log) log.innerHTML = ''
|
||||||
|
lastLog = ''
|
||||||
|
|
||||||
|
return step()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update fixed layer rows without destroying bars (so width transitions animate).
|
||||||
|
* @param {HTMLElement} pull
|
||||||
|
* @param {(object|null)[]} slots length 4
|
||||||
|
*/
|
||||||
|
function patchLayerSlots(pull, slots) {
|
||||||
|
let layersEl = $('.pull-progress-layers', pull)
|
||||||
|
if (!layersEl) return
|
||||||
|
|
||||||
|
let rows = [...layersEl.querySelectorAll(':scope > .pull-layer')]
|
||||||
|
if (rows.length !== LAYER_SLOTS) {
|
||||||
|
layersEl.innerHTML = Array.from({ length: LAYER_SLOTS }, (_, s) =>
|
||||||
|
emptyLayerRow(s)
|
||||||
|
).join('')
|
||||||
|
rows = [...layersEl.querySelectorAll(':scope > .pull-layer')]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let s = 0; s < LAYER_SLOTS; s++) {
|
||||||
|
const row = rows[s]
|
||||||
|
const L = slots[s] || null
|
||||||
|
if (!L) {
|
||||||
|
if (!row.classList.contains('pull-layer--empty')) {
|
||||||
|
row.className = 'pull-layer pull-layer--empty'
|
||||||
|
row.setAttribute('aria-hidden', 'true')
|
||||||
|
row.removeAttribute('data-layer-id')
|
||||||
|
row.innerHTML = emptyLayerInner()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameId = row.getAttribute('data-layer-id') === L.id
|
||||||
|
const phaseClass = `pull-layer pull-layer--${L.phase}`
|
||||||
|
row.setAttribute('aria-hidden', 'false')
|
||||||
|
row.setAttribute('data-layer-id', L.id)
|
||||||
|
row.setAttribute('data-layer-slot', String(s))
|
||||||
|
|
||||||
|
if (!sameId || row.classList.contains('pull-layer--empty')) {
|
||||||
|
row.className = phaseClass
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="pull-layer-id" title="${escapeAttr(L.id)}">${escapeHtml(L.shortId)}</span>
|
||||||
|
<div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:${L.fill}%"></div></div>
|
||||||
|
<span class="pull-layer-meta">${escapeHtml(L.meta)}</span>`
|
||||||
|
} else {
|
||||||
|
if (row.className !== phaseClass) row.className = phaseClass
|
||||||
|
const idEl = $('.pull-layer-id', row)
|
||||||
|
const metaEl = $('.pull-layer-meta', row)
|
||||||
|
const barFill = $('.pull-layer-bar-fill', row)
|
||||||
|
if (idEl && idEl.textContent !== L.shortId) {
|
||||||
|
idEl.textContent = L.shortId
|
||||||
|
idEl.title = L.id
|
||||||
|
}
|
||||||
|
if (metaEl && metaEl.textContent !== L.meta) metaEl.textContent = L.meta
|
||||||
|
if (barFill) {
|
||||||
|
// force reflow-friendly width update so CSS transition runs
|
||||||
|
const w = `${L.fill}%`
|
||||||
|
if (barFill.style.width !== w) barFill.style.width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyLayerRow(slot) {
|
||||||
|
return `<div class="pull-layer pull-layer--empty" data-layer-slot="${slot}" aria-hidden="true">${emptyLayerInner()}</div>`
|
||||||
|
}
|
||||||
|
function emptyLayerInner() {
|
||||||
|
return `<span class="pull-layer-id">…</span><div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div><span class="pull-layer-meta"></span>`
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return escapeHtml(s).replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tick-based pull simulator: up to MAX_CONCURRENT layers download in parallel,
|
||||||
|
* each bar fills independently; finished layers leave the slot for the next waiter.
|
||||||
|
* @param {{id:string,shortId:string,size:number}[]} catalog
|
||||||
|
*/
|
||||||
|
function simulatePull(catalog) {
|
||||||
|
const totalBytes = catalog.reduce((s, l) => s + l.size, 0)
|
||||||
|
/** @type {{id:string,shortId:string,size:number,state:'queued'|'downloading'|'downloaded'|'extracting'|'done',pct:number,speed:number}[]} */
|
||||||
|
const layers = catalog.map((l) => ({
|
||||||
|
...l,
|
||||||
|
state: 'queued',
|
||||||
|
pct: 0,
|
||||||
|
// larger layers download a bit slower per tick (MB-ish feel)
|
||||||
|
speed: Math.max(6, Math.min(22, 280 / l.size + Math.random() * 4)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** @type {any[]} */
|
||||||
|
const frames = []
|
||||||
|
|
||||||
|
const snapshot = (opts) => {
|
||||||
|
const {
|
||||||
|
status = 'running',
|
||||||
|
chip,
|
||||||
|
steps,
|
||||||
|
stepDetails = ['', '', '', ''],
|
||||||
|
phase,
|
||||||
|
phaseLabel,
|
||||||
|
subtitle = 'nginx:latest',
|
||||||
|
logLine = '',
|
||||||
|
} = opts
|
||||||
|
|
||||||
|
const downloadedBytes = layers.reduce((s, l) => {
|
||||||
|
if (l.state === 'done' || l.state === 'downloaded' || l.state === 'extracting') return s + l.size
|
||||||
|
if (l.state === 'downloading') return s + (l.size * l.pct) / 100
|
||||||
|
return s
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const doneCount = layers.filter(
|
||||||
|
(l) => l.state === 'done' || l.state === 'downloaded' || l.state === 'extracting'
|
||||||
|
).length
|
||||||
|
const dlCount = layers.filter((l) => l.state === 'downloading').length
|
||||||
|
const queued = layers.filter((l) => l.state === 'queued').length
|
||||||
|
|
||||||
|
const pips = layers.map((l) => {
|
||||||
|
if (l.state === 'done') return 'done'
|
||||||
|
if (l.state === 'extracting') return 'extract'
|
||||||
|
if (l.state === 'downloaded') return 'done'
|
||||||
|
if (l.state === 'downloading') return 'download'
|
||||||
|
if (l.state === 'queued') return 'wait'
|
||||||
|
return 'idle'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active slots: concurrent downloads first (stable order by catalog index), then extract
|
||||||
|
const active = []
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state === 'downloading') {
|
||||||
|
const got = (l.size * l.pct) / 100
|
||||||
|
active.push({
|
||||||
|
id: l.id,
|
||||||
|
shortId: l.shortId,
|
||||||
|
phase: 'downloading',
|
||||||
|
fill: Math.round(l.pct),
|
||||||
|
meta: `${got.toFixed(1)} / ${l.size} MB`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state === 'extracting' && active.length < LAYER_SLOTS) {
|
||||||
|
active.push({
|
||||||
|
id: l.id,
|
||||||
|
shortId: l.shortId,
|
||||||
|
phase: 'extracting',
|
||||||
|
fill: Math.round(l.pct),
|
||||||
|
meta: `extract ${Math.round(l.pct)}%`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Optionally show one queued layer as waiting in a free slot
|
||||||
|
if (active.length < LAYER_SLOTS) {
|
||||||
|
const nextQ = layers.find((l) => l.state === 'queued')
|
||||||
|
if (nextQ) {
|
||||||
|
active.push({
|
||||||
|
id: nextQ.id,
|
||||||
|
shortId: nextQ.shortId,
|
||||||
|
phase: 'waiting',
|
||||||
|
fill: 8,
|
||||||
|
meta: 'waiting',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (active.length < LAYER_SLOTS) active.push(null)
|
||||||
|
|
||||||
|
let barWidth = 8
|
||||||
|
let pctText = '…'
|
||||||
|
if (phase === 'downloading' || phase === 'extracting') {
|
||||||
|
// download weight 0 to 85%, extract 85 to 98%
|
||||||
|
const dlFrac = downloadedBytes / totalBytes
|
||||||
|
if (phase === 'downloading') {
|
||||||
|
barWidth = Math.min(85, Math.round(12 + dlFrac * 73))
|
||||||
|
} else {
|
||||||
|
const extractDone = layers.filter((l) => l.state === 'done').length
|
||||||
|
barWidth = Math.min(98, Math.round(85 + (extractDone / layers.length) * 13))
|
||||||
|
}
|
||||||
|
pctText = `${barWidth}%`
|
||||||
|
} else if (phase === 'complete') {
|
||||||
|
barWidth = 100
|
||||||
|
pctText = '100%'
|
||||||
|
} else if (phase === 'resolving') {
|
||||||
|
barWidth = opts.barWidth ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
phase === 'downloading'
|
||||||
|
? `${doneCount}/${layers.length} layers · ${downloadedBytes.toFixed(1)} MB`
|
||||||
|
: phase === 'extracting'
|
||||||
|
? `Extracting · ${layers.filter((l) => l.state === 'done').length}/${layers.length}`
|
||||||
|
: phase === 'complete'
|
||||||
|
? opts.summary || 'Deploy finished'
|
||||||
|
: opts.summary || 'nginx:latest'
|
||||||
|
|
||||||
|
const activity =
|
||||||
|
opts.activity ||
|
||||||
|
(dlCount
|
||||||
|
? `Downloading ${dlCount} layer${dlCount > 1 ? 's' : ''} in parallel…`
|
||||||
|
: phase === 'extracting'
|
||||||
|
? 'Extracting layers…'
|
||||||
|
: '')
|
||||||
|
|
||||||
|
const waiting =
|
||||||
|
queued > 0 && (phase === 'downloading' || phase === 'resolving')
|
||||||
|
? `${queued} layer${queued === 1 ? '' : 's'} waiting`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
frames.push({
|
||||||
|
status,
|
||||||
|
chip,
|
||||||
|
subtitle,
|
||||||
|
steps,
|
||||||
|
stepDetails,
|
||||||
|
phase,
|
||||||
|
phaseLabel,
|
||||||
|
summary,
|
||||||
|
pctText,
|
||||||
|
barWidth,
|
||||||
|
pips: [...pips],
|
||||||
|
activeLayers: active,
|
||||||
|
waiting,
|
||||||
|
activity,
|
||||||
|
logLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve + auth
|
||||||
|
snapshot({
|
||||||
|
chip: '1/4',
|
||||||
|
steps: ['active', 'pending', 'pending', 'pending'],
|
||||||
|
stepDetails: ['Looking up registry…', '', '', ''],
|
||||||
|
phase: 'resolving',
|
||||||
|
phaseLabel: 'Resolving',
|
||||||
|
summary: 'nginx:latest',
|
||||||
|
barWidth: 6,
|
||||||
|
activity: 'Resolving image nginx:latest…',
|
||||||
|
logLine: 'Resolving nginx:latest from registry-1.docker.io',
|
||||||
|
})
|
||||||
|
snapshot({
|
||||||
|
chip: '1/4',
|
||||||
|
steps: ['active', 'pending', 'pending', 'pending'],
|
||||||
|
stepDetails: ['Public · no credential', '', '', ''],
|
||||||
|
phase: 'resolving',
|
||||||
|
phaseLabel: 'Auth',
|
||||||
|
summary: 'public',
|
||||||
|
barWidth: 12,
|
||||||
|
activity: 'Authenticated (anonymous)',
|
||||||
|
logLine: 'Auth: public (no credential)',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark all queued with wait pips
|
||||||
|
layers.forEach((l) => {
|
||||||
|
l.state = 'queued'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Seed concurrent downloads
|
||||||
|
const startNextDownloads = () => {
|
||||||
|
const active = layers.filter((l) => l.state === 'downloading').length
|
||||||
|
let slots = MAX_CONCURRENT - active
|
||||||
|
for (const l of layers) {
|
||||||
|
if (slots <= 0) break
|
||||||
|
if (l.state === 'queued') {
|
||||||
|
l.state = 'downloading'
|
||||||
|
l.pct = 0
|
||||||
|
slots -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startNextDownloads()
|
||||||
|
|
||||||
|
snapshot({
|
||||||
|
chip: '2/4',
|
||||||
|
steps: ['success', 'active', 'pending', 'pending'],
|
||||||
|
stepDetails: ['', `${MAX_CONCURRENT} concurrent`, '', ''],
|
||||||
|
phase: 'downloading',
|
||||||
|
phaseLabel: 'Downloading',
|
||||||
|
logLine: `Starting pull · ${MAX_CONCURRENT} concurrent layer downloads`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download ticks until all downloaded
|
||||||
|
let guard = 0
|
||||||
|
while (layers.some((l) => l.state === 'queued' || l.state === 'downloading') && guard < 200) {
|
||||||
|
guard += 1
|
||||||
|
startNextDownloads()
|
||||||
|
|
||||||
|
const finishedThisTick = []
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state !== 'downloading') return
|
||||||
|
// advance fill (not uniform so bars look independent)
|
||||||
|
const step = l.speed * (0.75 + Math.random() * 0.5)
|
||||||
|
l.pct = Math.min(100, l.pct + step)
|
||||||
|
if (l.pct >= 100) {
|
||||||
|
l.pct = 100
|
||||||
|
l.state = 'downloaded'
|
||||||
|
finishedThisTick.push(l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
startNextDownloads()
|
||||||
|
|
||||||
|
const logLine =
|
||||||
|
finishedThisTick.length > 0
|
||||||
|
? `Downloaded ${finishedThisTick.map((l) => l.shortId).join(', ')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
snapshot({
|
||||||
|
chip: '2/4',
|
||||||
|
steps: ['success', 'active', 'pending', 'pending'],
|
||||||
|
stepDetails: [
|
||||||
|
'',
|
||||||
|
`${layers.filter((l) => l.state === 'downloading').length} active · ${layers.filter((l) => l.state === 'downloaded' || l.state === 'done').length}/${layers.length} done`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
phase: 'downloading',
|
||||||
|
phaseLabel: 'Downloading',
|
||||||
|
logLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract phase: up to 2 at a time, fill 0 to 100
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state === 'downloaded') {
|
||||||
|
l.state = 'queued' // reuse as extract queue
|
||||||
|
l.pct = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// rename: use extracting queue from downloaded
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state === 'queued' && l.pct === 0) {
|
||||||
|
/* already set */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert: downloaded → waiting for extract (state 'queued' means extract queue now)
|
||||||
|
// Actually all should be 'downloaded'. Put them in extract queue.
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state === 'downloaded') {
|
||||||
|
l.state = 'queued'
|
||||||
|
l.pct = 0
|
||||||
|
l.speed = 18 + Math.random() * 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const startNextExtracts = () => {
|
||||||
|
const active = layers.filter((l) => l.state === 'extracting').length
|
||||||
|
let slots = 2 - active
|
||||||
|
for (const l of layers) {
|
||||||
|
if (slots <= 0) break
|
||||||
|
if (l.state === 'queued') {
|
||||||
|
l.state = 'extracting'
|
||||||
|
l.pct = 0
|
||||||
|
slots -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startNextExtracts()
|
||||||
|
|
||||||
|
snapshot({
|
||||||
|
chip: '2/4',
|
||||||
|
steps: ['success', 'active', 'pending', 'pending'],
|
||||||
|
stepDetails: ['', 'Extracting layers…', '', ''],
|
||||||
|
phase: 'extracting',
|
||||||
|
phaseLabel: 'Extracting',
|
||||||
|
logLine: 'Extracting layers…',
|
||||||
|
})
|
||||||
|
|
||||||
|
guard = 0
|
||||||
|
while (layers.some((l) => l.state === 'queued' || l.state === 'extracting') && guard < 120) {
|
||||||
|
guard += 1
|
||||||
|
startNextExtracts()
|
||||||
|
const finished = []
|
||||||
|
layers.forEach((l) => {
|
||||||
|
if (l.state !== 'extracting') return
|
||||||
|
l.pct = Math.min(100, l.pct + l.speed)
|
||||||
|
if (l.pct >= 100) {
|
||||||
|
l.pct = 100
|
||||||
|
l.state = 'done'
|
||||||
|
finished.push(l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
startNextExtracts()
|
||||||
|
snapshot({
|
||||||
|
chip: '2/4',
|
||||||
|
steps: ['success', 'active', 'pending', 'pending'],
|
||||||
|
stepDetails: [
|
||||||
|
'',
|
||||||
|
`${layers.filter((l) => l.state === 'done').length}/${layers.length} extracted`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
phase: 'extracting',
|
||||||
|
phaseLabel: 'Extracting',
|
||||||
|
logLine: finished.length ? `Extracted ${finished.map((l) => l.shortId).join(', ')}` : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.forEach((l) => {
|
||||||
|
l.state = 'done'
|
||||||
|
l.pct = 100
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot({
|
||||||
|
chip: '3/4',
|
||||||
|
steps: ['success', 'success', 'active', 'pending'],
|
||||||
|
stepDetails: ['', '', 'Applying config…', ''],
|
||||||
|
phase: 'complete',
|
||||||
|
phaseLabel: 'Complete',
|
||||||
|
summary: 'Image ready',
|
||||||
|
subtitle: 'peardock-demo',
|
||||||
|
activity: 'Creating container peardock-demo…',
|
||||||
|
logLine: 'docker create peardock-demo',
|
||||||
|
})
|
||||||
|
snapshot({
|
||||||
|
chip: '4/4',
|
||||||
|
steps: ['success', 'success', 'success', 'active'],
|
||||||
|
stepDetails: ['', '', '', '8080→80'],
|
||||||
|
phase: 'complete',
|
||||||
|
phaseLabel: 'Complete',
|
||||||
|
summary: 'Image ready',
|
||||||
|
subtitle: 'peardock-demo',
|
||||||
|
activity: 'Starting container…',
|
||||||
|
logLine: 'Started peardock-demo · 0.0.0.0:8080->80/tcp',
|
||||||
|
})
|
||||||
|
snapshot({
|
||||||
|
status: 'success',
|
||||||
|
chip: '4/4 done',
|
||||||
|
steps: ['success', 'success', 'success', 'success'],
|
||||||
|
stepDetails: ['', '', '', 'Running'],
|
||||||
|
phase: 'complete',
|
||||||
|
phaseLabel: 'Complete',
|
||||||
|
summary: 'Deploy finished',
|
||||||
|
subtitle: 'peardock-demo',
|
||||||
|
activity: 'Container running · ports 8080→80',
|
||||||
|
logLine: 'Job complete',
|
||||||
|
})
|
||||||
|
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Containers: pulse update badges ── */
|
||||||
|
function startContainersDemo(root) {
|
||||||
|
const stage = $('[data-demo-panel="containers"]', root)
|
||||||
|
if (!stage) return null
|
||||||
|
const rows = $$('tbody tr', stage)
|
||||||
|
let i = 0
|
||||||
|
return setInterval(() => {
|
||||||
|
rows.forEach((r) => r.classList.remove('is-highlight'))
|
||||||
|
const row = rows[i % rows.length]
|
||||||
|
if (row) row.classList.add('is-highlight')
|
||||||
|
i += 1
|
||||||
|
}, reduced() ? 2000 : 1600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Connect / restore ── */
|
||||||
|
function startConnectDemo(root) {
|
||||||
|
const stage = $('[data-demo-panel="connect"]', root)
|
||||||
|
if (!stage) return null
|
||||||
|
const status = $('[data-connect-status]', stage)
|
||||||
|
const bar = $('[data-connect-bar]', stage)
|
||||||
|
const count = $('[data-connect-count]', stage)
|
||||||
|
const phases = [
|
||||||
|
{ text: 'Dialing HyperDHT…', pct: 15, n: '0 of 2' },
|
||||||
|
{ text: 'Connecting Laptop…', pct: 45, n: '0 of 2' },
|
||||||
|
{ text: 'Laptop online · 14 ms', pct: 70, n: '1 of 2' },
|
||||||
|
{ text: 'Connecting staging…', pct: 85, n: '1 of 2' },
|
||||||
|
{ text: '2 peers restored', pct: 100, n: '2 of 2' },
|
||||||
|
]
|
||||||
|
let i = 0
|
||||||
|
const tick = () => {
|
||||||
|
const p = phases[i]
|
||||||
|
if (status) status.textContent = p.text
|
||||||
|
if (bar) bar.style.width = `${p.pct}%`
|
||||||
|
if (count) count.textContent = p.n
|
||||||
|
i = (i + 1) % phases.length
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
return setInterval(tick, reduced() ? 900 : 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero mock: live meters + compact job tray card ── */
|
||||||
|
function initHeroMock() {
|
||||||
|
const root = $('[data-hero-mock]')
|
||||||
|
if (!root || reduced()) return
|
||||||
|
|
||||||
|
const cpu = $('[data-live-cpu]', root)
|
||||||
|
const mem = $('[data-live-mem]', root)
|
||||||
|
const cpuBar = $('[data-live-cpu-bar]', root)
|
||||||
|
const memBar = $('[data-live-mem-bar]', root)
|
||||||
|
const events = $('[data-live-events]', root)
|
||||||
|
const eventLines = [
|
||||||
|
'container start · vpn',
|
||||||
|
'container exec · healthcheck',
|
||||||
|
'image pull · redis:7',
|
||||||
|
'network connect · bridge',
|
||||||
|
'volume mount · data',
|
||||||
|
'container restart · api',
|
||||||
|
]
|
||||||
|
|
||||||
|
let eIdx = 0
|
||||||
|
setInterval(() => {
|
||||||
|
if (cpu) {
|
||||||
|
const v = 8 + Math.round(Math.random() * 22)
|
||||||
|
cpu.textContent = `${v}%`
|
||||||
|
if (cpuBar) cpuBar.style.width = `${v}%`
|
||||||
|
}
|
||||||
|
if (mem) {
|
||||||
|
const v = 32 + Math.round(Math.random() * 18)
|
||||||
|
mem.textContent = `${v}%`
|
||||||
|
if (memBar) memBar.style.width = `${v}%`
|
||||||
|
}
|
||||||
|
if (events) {
|
||||||
|
eIdx = (eIdx + 1) % eventLines.length
|
||||||
|
const line = document.createElement('div')
|
||||||
|
line.className = 'mock-event-line'
|
||||||
|
const t = new Date().toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
line.innerHTML = `<span class="mock-event-time">${t}</span> ${eventLines[eIdx]}`
|
||||||
|
events.prepend(line)
|
||||||
|
while (events.children.length > 5) events.lastElementChild?.remove()
|
||||||
|
}
|
||||||
|
}, 1800)
|
||||||
|
|
||||||
|
const mini = $('[data-hero-job]', root)
|
||||||
|
if (mini) {
|
||||||
|
const bar = $('[data-hero-job-bar]', mini)
|
||||||
|
const label = $('[data-hero-job-label]', mini)
|
||||||
|
const states = [
|
||||||
|
{ pct: 12, label: 'Pull nginx:latest…' },
|
||||||
|
{ pct: 38, label: 'Extracting layers…' },
|
||||||
|
{ pct: 67, label: 'Creating container…' },
|
||||||
|
{ pct: 100, label: 'Deploy complete' },
|
||||||
|
]
|
||||||
|
let j = 0
|
||||||
|
const loop = () => {
|
||||||
|
const s = states[j]
|
||||||
|
if (bar) bar.style.width = `${s.pct}%`
|
||||||
|
if (label) label.textContent = s.label
|
||||||
|
mini.classList.toggle('is-done', s.pct === 100)
|
||||||
|
j = (j + 1) % states.length
|
||||||
|
setTimeout(loop, s.pct === 100 ? 2200 : 1100)
|
||||||
|
}
|
||||||
|
loop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initDemoTabs()
|
||||||
|
initHeroMock()
|
||||||
|
})
|
||||||
@@ -41,6 +41,7 @@ function injectChrome() {
|
|||||||
<button type="button" class="nav-toggle" aria-label="Menu" aria-expanded="false">☰</button>
|
<button type="button" class="nav-toggle" aria-label="Menu" aria-expanded="false">☰</button>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="/#features">Features</a></li>
|
<li><a href="/#features">Features</a></li>
|
||||||
|
<li><a href="/#product-tour">Demos</a></li>
|
||||||
<li><a href="/learn/">How it works</a></li>
|
<li><a href="/learn/">How it works</a></li>
|
||||||
<li><a href="/docs/">Docs</a></li>
|
<li><a href="/docs/">Docs</a></li>
|
||||||
<li><a href="/download">Download</a></li>
|
<li><a href="/download">Download</a></li>
|
||||||
@@ -71,6 +72,7 @@ function injectChrome() {
|
|||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Product</h4>
|
<h4>Product</h4>
|
||||||
<a href="/#features">Features</a>
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#product-tour">Live demos</a>
|
||||||
<a href="/download">Download</a>
|
<a href="/download">Download</a>
|
||||||
<a href="${RELEASES_PAGE}">Releases</a>
|
<a href="${RELEASES_PAGE}">Releases</a>
|
||||||
<a href="${COMMUNITY_PAGE}">Community</a>
|
<a href="${COMMUNITY_PAGE}">Community</a>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════
|
||||||
PearDock Docs — product-grade documentation layout
|
PearDock Docs · product-grade documentation layout
|
||||||
Balanced gutters · sticky nav · refined prose · dark chrome
|
Balanced gutters · sticky nav · refined prose · dark chrome
|
||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ body.is-docs .nav-brand-text {
|
|||||||
minmax(0.75rem, 1.15fr);
|
minmax(0.75rem, 1.15fr);
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
column-gap: 0;
|
column-gap: 0;
|
||||||
/* Grow with content — do not force a second inner viewport */
|
/* Grow with content · do not force a second inner viewport */
|
||||||
min-height: calc(100vh - var(--nav-h));
|
min-height: calc(100vh - var(--nav-h));
|
||||||
margin-top: var(--nav-h);
|
margin-top: var(--nav-h);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -251,11 +251,11 @@ body.is-docs .nav-brand-text {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* Let the window scroll — no nested main scrollbar */
|
/* Let the window scroll · no nested main scrollbar */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
/* More horizontal padding on the right so wide content can breathe */
|
/* More horizontal padding on the right so wide content can breathe */
|
||||||
padding: 2.25rem clamp(1.25rem, 2.5vw, 2.5rem) 5.5rem clamp(1.5rem, 2.5vw, 2.75rem);
|
padding: 2.25rem clamp(1.25rem, 2.5vw, 2.5rem) 5.5rem clamp(1.5rem, 2.5vw, 2.75rem);
|
||||||
/* Solid reading surface — not a floating island */
|
/* Solid reading surface · not a floating island */
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(15, 17, 23, 0.35) 0%, transparent 12rem),
|
linear-gradient(180deg, rgba(15, 17, 23, 0.35) 0%, transparent 12rem),
|
||||||
transparent;
|
transparent;
|
||||||
@@ -721,7 +721,7 @@ body.is-docs .nav-brand-text {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Odd last card (e.g. FAQ on docs overview) — center under the two-column grid */
|
/* Odd last card (e.g. FAQ on docs overview) · center under the two-column grid */
|
||||||
.docs-cards > .docs-card:last-child:nth-child(odd) {
|
.docs-cards > .docs-card:last-child:nth-child(odd) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1193,7 +1193,7 @@ body .container {
|
|||||||
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(45, 212, 191, 0.06), transparent 55%),
|
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(45, 212, 191, 0.06), transparent 55%),
|
||||||
linear-gradient(165deg, rgba(15, 17, 23, 0.95), rgba(8, 10, 14, 0.98));
|
linear-gradient(165deg, rgba(15, 17, 23, 0.95), rgba(8, 10, 14, 0.98));
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||||
/* Prefer scaling SVG via useMaxWidth — avoid nested scrollbars when possible */
|
/* Prefer scaling SVG via useMaxWidth · avoid nested scrollbars when possible */
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════
|
||||||
PearDock Website — Design System
|
PearDock Website · Design System
|
||||||
Brand tokens: #0f1117 · #2dd4bf · #f4f7fb
|
Brand tokens: #0f1117 · #2dd4bf · #f4f7fb
|
||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ pre {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.15rem 1.25rem;
|
padding: 1.15rem 1.25rem;
|
||||||
/* Long curl URLs have no spaces — break mid-token, never h-scroll */
|
/* Long curl URLs have no spaces · break mid-token, never h-scroll */
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -622,7 +622,7 @@ tr:hover td {
|
|||||||
/*
|
/*
|
||||||
* Bottom-only cradle: true semicircle under the pear.
|
* Bottom-only cradle: true semicircle under the pear.
|
||||||
* Full disc is positioned with its center on the bar bottom, then the top
|
* Full disc is positioned with its center on the bar bottom, then the top
|
||||||
* half is clipped away — so only a curve leaves and returns to the bar.
|
* half is clipped away · so only a curve leaves and returns to the bar.
|
||||||
* No top ring, no diameter stroke through the pear.
|
* No top ring, no diameter stroke through the pear.
|
||||||
*/
|
*/
|
||||||
.nav-brand::after {
|
.nav-brand::after {
|
||||||
@@ -651,7 +651,7 @@ tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-nav.scrolled .nav-brand::after {
|
.site-nav.scrolled .nav-brand::after {
|
||||||
/* Same glass + border as the bar — continuous under the pear */
|
/* Same glass + border as the bar · continuous under the pear */
|
||||||
background: var(--bg-glass);
|
background: var(--bg-glass);
|
||||||
backdrop-filter: blur(16px) saturate(1.4);
|
backdrop-filter: blur(16px) saturate(1.4);
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||||
@@ -688,7 +688,7 @@ tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Free-floating pear — top stays inside the bar (no viewport clip);
|
* Free-floating pear · top stays inside the bar (no viewport clip);
|
||||||
* bottom hangs below and sits in the circular cradle.
|
* bottom hangs below and sits in the circular cradle.
|
||||||
*/
|
*/
|
||||||
.nav-brand-logo {
|
.nav-brand-logo {
|
||||||
@@ -954,7 +954,7 @@ body.docs-sidebar-open {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
:root {
|
:root {
|
||||||
--nav-h: 60px;
|
--nav-h: 60px;
|
||||||
/* Fit fully inside the bar — no overflow / cradle on mobile */
|
/* Fit fully inside the bar · no overflow / cradle on mobile */
|
||||||
--brand-logo: 44px;
|
--brand-logo: 44px;
|
||||||
--brand-top-inset: 0px;
|
--brand-top-inset: 0px;
|
||||||
--brand-overhang: 0px;
|
--brand-overhang: 0px;
|
||||||
@@ -1005,7 +1005,7 @@ body.docs-sidebar-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand-logo {
|
.nav-brand-logo {
|
||||||
/* Sit flush in the bar — no hang above/below */
|
/* Sit flush in the bar · no hang above/below */
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: var(--brand-logo);
|
width: var(--brand-logo);
|
||||||
@@ -1036,7 +1036,7 @@ body.docs-sidebar-open {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom-color: var(--border);
|
border-bottom-color: var(--border);
|
||||||
max-height: min(100dvh, 100vh);
|
max-height: min(100dvh, 100vh);
|
||||||
/* Drawer scrolls; pear may clip while open — ok vs closed overflow */
|
/* Drawer scrolls; pear may clip while open · ok vs closed overflow */
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|||||||
+1001
-5
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user