From c3009b4a1bdc383b36682ff475ad3287db4a4f50 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Thu, 16 Jul 2026 16:06:42 -0400 Subject: [PATCH] Updates --- README.md | 4 +- docs/api.html | 8 +- docs/faq.html | 12 +- docs/index.html | 2 +- docs/operator.html | 6 +- docs/quickstart.html | 6 +- docs/security.html | 18 +- download.html | 4 +- index.html | 571 +++++++++++++++-- learn/index.html | 2 +- server.mjs | 6 +- src/js/home-demos.js | 869 +++++++++++++++++++++++++ src/js/site.js | 2 + src/styles/app-chrome.css | 1275 +++++++++++++++++++++++++++++++++++++ src/styles/docs.css | 12 +- src/styles/global.css | 16 +- src/styles/home.css | 1006 ++++++++++++++++++++++++++++- 17 files changed, 3719 insertions(+), 100 deletions(-) create mode 100644 src/js/home-demos.js create mode 100644 src/styles/app-chrome.css diff --git a/README.md b/README.md index 3087006..c15a31b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Official marketing site and documentation for [peardock](https://peardock.boats) ([source](https://git.ssh.surf/snxraven/peardock)). 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) @@ -45,7 +45,7 @@ When peardock ships UI or RPC surface changes, update: 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) - Image update indicators / `checkImageUpdates` - Job tray + hybrid pull progress diff --git a/docs/api.html b/docs/api.html index ebafa39..b1bbbb1 100644 --- a/docs/api.html +++ b/docs/api.html @@ -120,7 +120,7 @@

Handshake

On connect, the client calls handshake. The server returns:

diff --git a/docs/faq.html b/docs/faq.html index f6d8bee..5a9a33c 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -124,8 +124,8 @@

What do I share with clients?

Never share SERVER_SEED with operators.

@@ -149,8 +149,8 @@ sudo journalctl -u peardock -n 80 --no-pager | grep -i 'public key'

As admin (public key + SERVER_SEED in Add peer), open Access → Create invite and copy the full string starting with pd1.. The operator pastes that entire string in Add peer. - Public key and HMAC capability are embedded — no Autopass/RocksDB and no seed sharing. - Invites can be persistent (default) or limited by TTL / max uses. Revoke peers from Access when devices are lost. + Public key and HMAC capability are embedded. No Autopass or RocksDB. No seed sharing. + Invites can be persistent (default) or limited by TTL or max uses. Revoke peers from Access when devices are lost. Deep dive: Security model.

@@ -210,7 +210,7 @@ npm run sign:macos -- out/peardock-darwin-arm64/peardock.app

Where did Peers go? Where are saved hosts?

Settings → Peers manages client-side saved hosts (public keys, aliases). - Server-side ACL — invites, revoke, roles — stays under Access. + Server-side ACL for invites, revoke, and roles stays under Access.

What does the Updates column on containers mean?

@@ -226,7 +226,7 @@ npm run sign:macos -- out/peardock-darwin-arm64/peardock.app

Pull and push stream layer events over push:pullProgress / push:pushProgress. 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.

How do I update?

diff --git a/docs/index.html b/docs/index.html index 9caee6e..6b5b6ac 100644 --- a/docs/index.html +++ b/docs/index.html @@ -171,7 +171,7 @@ 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 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.

diff --git a/docs/operator.html b/docs/operator.html index 4a53203..7196be6 100644 --- a/docs/operator.html +++ b/docs/operator.html @@ -127,7 +127,7 @@ sudo journalctl -u peardock -f SERVER_PUBLIC_KEYSafe to shareAlone → viewer - SERVER_SEEDAdmins only — never share with operatorsWith public key → admin + SERVER_SEEDAdmins only. Never share with operators.With public key → admin
@@ -222,7 +222,7 @@ PEARDOCK_BROWSE_ROOTS=/var/lib/docker/volumes PEARDOCK_AUDIT=1
  1. As admin: Access → Create invite → copy the full pd1.… invite string (never truncate).
  2. -
  3. Operator pastes the full pd1. invite in Add peer — the client decodes public key + HMAC capability and connects with the granted role (no seed, no RocksDB/Autopass).
  4. +
  5. Operator pastes the full pd1. invite in Add peer. The client decodes public key and HMAC capability, then connects with the granted role (no seed, no RocksDB or Autopass).
  6. Revoke lost devices from Access. Delete spent invites before minting a replacement if needed.
@@ -245,7 +245,7 @@ PEARDOCK_AUDIT=1

Registry vault and browser

Credentials are encrypted at rest (AES-GCM; KDF is Bare-safe). - The desktop app has a top-level Registry view — not under Access. + The desktop app has a top-level Registry view. It is not under Access.

diff --git a/docs/security.html b/docs/security.html index fcbcf5a..6bf4832 100644 --- a/docs/security.html +++ b/docs/security.html @@ -130,7 +130,7 @@

PearDock is a self-hosted P2P Docker control plane. There is no central login. Anyone who knows the server public key can attempt a HyperDHT dial. - That alone must not mean full Docker control — so the default role is viewer (read-only). + That alone must not mean full Docker control. The default role is viewer (read-only).

[Desktop client] --Noise/HyperDHT--> [peardock-server] --unix socket--> [dockerd]
                                           |
@@ -147,7 +147,7 @@
           
             Public key only (SERVER_PUBLIC_KEY)Viewer (read-only)
             Public key + SERVER_SEEDAdmin (seed never sent on the wire)
-            Full pd1.… inviteRole in the invite — no seed sharing
+            Full pd1.… inviteRole in the invite. No seed sharing.
           
         
       
@@ -228,8 +228,8 @@
       

4. Handshake elevation

Baseline role (usually viewer) is resolved from env / admin keys / peer policy. Then handshake may elevate:

    -
  1. adminProof — seed ownership → admin
  2. -
  3. capability — HMAC grant from a pd1. invite (or direct token)
  4. +
  5. adminProof: seed ownership maps to admin
  6. +
  7. capability: HMAC grant from a pd1. invite (or direct token)
  8. Otherwise stay at baseline
@@ -247,7 +247,7 @@

Optional: PEARDOCK_ADMIN_KEYS (fixed admin peer ids), PEARDOCK_DEFAULT_ROLE, PEARDOCK_PEER_ALLOWLIST=1, - PEARDOCK_INSECURE_OPEN_ADMIN=1 (dev only — everyone is admin). + PEARDOCK_INSECURE_OPEN_ADMIN=1 (dev only; everyone is admin).

5. Admin seed proof

@@ -305,10 +305,10 @@

7. Registration, reconnect, revoke

State lives in peardock-peers.json (override with PEARDOCK_PEER_POLICY):

    -
  • peers — registered elevated client identities for reconnect
  • -
  • capabilities — active grants (by jti)
  • -
  • spentJtis — deleted or exhausted grants
  • -
  • revoked — hard ban list of peer ids
  • +
  • peers: registered elevated client identities for reconnect
  • +
  • capabilities: active grants (by jti)
  • +
  • spentJtis: deleted or exhausted grants
  • +
  • revoked: hard ban list of peer ids

After a successful elevate, reconnect can use the registered role even if that invite jti is later deleted. diff --git a/download.html b/download.html index c3f9a1c..5a669c1 100644 --- a/download.html +++ b/download.html @@ -147,12 +147,12 @@ curl -fsSL https://install.peardock.boats | bash -s -- --both --yes

  • Installs binary to /opt/peardock/peardock-server
  • Creates system user peardock and adds it to the docker group (for /var/run/docker.sock)
  • Writes /etc/systemd/system/peardock.service and optionally enables it
  • -
  • Creates /opt/peardock/.env (mode 600) — check this file after first start for your keys
  • +
  • Creates /opt/peardock/.env (mode 600). Check this file after first start for your keys.
  • sudo systemctl status peardock
     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
     
     # SERVER_PUBLIC_KEY → share for viewer / paste in client
    diff --git a/index.html b/index.html
    index b5589f9..20f6201 100644
    --- a/index.html
    +++ b/index.html
    @@ -3,8 +3,8 @@
     
       
       
    -  PearDock · Decentralized Docker management
    -  
    +  PearDock · P2P Docker ops without open ports
    +  
       
       
       
    @@ -27,8 +27,8 @@
       
       
       
    -  
    -  
    +  
    +  
       
       
       
    @@ -38,8 +38,8 @@
     
       
       
    -  
    -  
    +  
    +  
       
       
     
    @@ -136,8 +136,10 @@
       
       
       
    +  
       
       
    +  
     
     
       
    @@ -152,47 +154,118 @@
             
    - v2 · HyperDHT · AGPL-3.0 + v2 · Live job tray · AGPL-3.0

    - Docker ops without
    - opening ports + Remote Docker that
    + never needs a port

    - Peer-to-peer Docker management on the Holepunch stack. - Share a public key, then manage containers, stacks, and tunnels over encrypted P2P. - Never open ports, expose Docker to the internet, or worry about remote access again. - No SaaS. No VPN. No inbound firewall rules. + PearDock is a peer-to-peer ops console for Docker on HyperDHT. + Connect with a public key, then run containers, stacks, terminals, and tunnels over Noise-encrypted RPC.

    + No open ports. No VPN. No SaaS middleman.

    - HyperDHT - Transport + Multi-peer fleet + Many hosts, one client
    - protomux-rpc - RPC protocol -
    -
    - 6 platforms - Server + client + Job tray + Pull · deploy · prune
    Holesail - Port tunnels + Service tunnels +
    +
    + 6 platforms + Server + desktop +
    +
    +
    +
    + Identity is a keypair + Share a 64-hex public key. Nothing listens on a public Docker port. +
    +
    + Ops that feel local + Dashboard, terminals, image updates, stacks, and registry vault in one dark UI. +
    +
    + Progress you can trust + Multi-step job tray with hybrid layer pull and push. Not a spinner and a prayer.
    -
    +
    -
    - PearDock desktop client +
    +
    + +
    +
    +
    + Job tray + Activity +
    +

    Pull nginx:latest…

    +
    +
    +
    +
    • @@ -225,6 +298,390 @@
    + +
    +
    +
    + +

    The real console, animated

    +
    + +
    + + + + +
    + + +
    +
    +
    +
    + +
    + + peardock P2P Docker +
    +
    PearDock
    +
    Laptop · 14 ms · ADMIN
    +
    +
    + +
    +
    +
    +
    +

    Overview

    +

    Dashboard

    +

    Live engine health, storage, and activity on the active peer

    +
    +
    + + Deploy +
    +
    +
    +
    +
    +
    Running5
    +
    +
    +
    +
    +
    Stopped0
    +
    +
    +
    +
    +
    Images10
    +
    +
    +
    +
    +
    Networks3
    +
    +
    +
    +
    +
    +
    Engine
    +
    +
    Version27.0.2
    +
    Containers5
    +
    Running5
    +
    Images10
    +
    Storage driveroverlay2
    +
    +
    +
    +
    Host
    +
    +
    Memory10.74 GB
    +
    CPU cores8
    +
    Operating systemUbuntu 26.04 LTS
    +
    Architecturex86_64
    +
    Kernel7.0.0-27-generic
    +
    +
    +
    +
    Disk
    +
    +
    Image layers3.57 GB
    +
    Images (10)3.57 GB
    +
    Containers (5)50.85 MB
    +
    Volumes (16)30.95 MB
    +
    Build cache0 B
    +
    +
    +
    +
    Live events
    +
    +
    04:22:09startcontainer vpn
    +
    04:22:09exechealthcheck vpn
    +
    04:22:04diecontainer vpn
    +
    04:21:59createcontainer api
    +
    +
    +
    +
    +
    Quick actions
    + Containers + Images + Prune images + System prune +
    +
    +
    +
    +
    +
    +
    +

    Dashboard that stays live

    +

    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.

    +
      +
    • Connection health: latency, Docker version, role
    • +
    • Disk bars from systemDf style metrics
    • +
    • Event timeline streaming from the active peer
    • +
    +
    +
    + + + + + + + + + +
    +
    +
    @@ -232,9 +689,9 @@

    Full Engine coverage.
    Zero ports to open.

    - Containers, images, volumes, networks, stacks, Swarm, terminals, logs, GitOps, - registry browser + vault, image update checks, Portainer-style templates, - roles, and Holesail tunnels in a desktop console built for real ops. + Containers, images, volumes, networks, stacks, Swarm, terminals, logs, and GitOps. + Registry browser and vault. Image update checks. Portainer-style templates. Roles and Holesail tunnels. + All in a desktop console built for real ops. Remote Docker without port forwards, public dockerd, or a permanent VPN.

    @@ -243,17 +700,17 @@

    No ports. No VPN.

    -

    Server identity is a HyperDHT keypair. Share the public key; peers holepunch. Nothing phones home, nothing listens on a public Docker port.

    +

    Server identity is a HyperDHT keypair. Share the public key. Peers holepunch. Nothing phones home. Nothing listens on a public Docker port.

    🔐

    Noise encryption

    -

    Every control connection is end-to-end encrypted. Roles (viewer, operator, admin), allowlists, pd1 invites, and audit logging help multi-operator fleets stay safe.

    +

    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.

    🚢

    Docker ops console

    -

    Deploy templates, add containers, recreate, prune, stack env files, image update indicators, secrets/configs, and schedules. Built for real operators.

    +

    Deploy templates. Add containers. Recreate and prune. Stack env files, image update indicators, secrets, configs, and schedules. Built for real operators.

    @@ -263,7 +720,7 @@
    🖥️

    Desktop client

    -

    Electron (or Pear) with multi-peer fleet, multi-step job tray and hybrid pull progress, live stats, terminals, and OTA-ready packaging.

    +

    Electron or Pear. Multi-peer fleet. Multi-step job tray with hybrid pull progress. Live stats, terminals, and OTA-ready packaging.

    📦
    @@ -281,18 +738,37 @@
    - +
    - -

    Two planes. Still zero open ports.

    + +

    Keys, peers, Docker.
    Still zero open ports.

    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.

    +
    +
    +
    1
    +

    Run the server

    +

    On a host with Docker, install via one-liner or binary. Identity is a HyperDHT keypair printed at start: SERVER_PUBLIC_KEY and seed.

    +
    +
    +
    2
    +

    Launch the client

    +

    Electron desktop (or Pear). Paste a public key, key + seed, or a pd1. invite. Multi-peer fleet lives in the sidebar.

    +
    +
    +
    3
    +

    Operate

    +

    Deploy, terminal, logs, stacks, registry, tunnels. Long work streams through the job tray with real progress.

    +
    +
    +
    @@ -322,7 +798,7 @@

    Control plane

    -

    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.

    +

    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.

    Data / tunnels

    @@ -343,8 +819,8 @@
    -

    Multi-peer. Multi-host. One client.

    -

    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.

    +

    Multi-peer. Multi-host.
    One client.

    +

    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.

    • Peer disk cache under ~/.config/peardock
    • Connection health badge: latency, Docker status, role
    • @@ -361,7 +837,7 @@

      Deploy like you mean it.

      -

      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.

      +

      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. See the demo.

      • Container and stack templates; always-pull on create / duplicate
      • GitOps-friendly stack deploy; remove cleans project networks
      • @@ -377,16 +853,16 @@
        -

        Share a service, never open a port.

        +

        Share a service.
        Never open a port.

        Create a Holesail tunnel from a container or host port. Copy the hs:// URL. Remote peers bind locally. You never punch a firewall hole or expose Docker to the internet.

        • No inbound port forwards. P2P hole punching instead.
        • -
        • Secure mode by default (capability ≠ raw listen key)
        • -
        • SSRF guard: loopback / allowlisted hosts only
        • -
        • Max concurrent tunnels · audited create/close
        • +
        • Secure mode by default (capability is not a raw listen key)
        • +
        • SSRF guard: loopback and allowlisted hosts only
        • +
        • Max concurrent tunnels. Audited create and close.
        Holesail guide →
        @@ -432,8 +908,8 @@

        Install in one line. Connect in three steps.

        - 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. + No public IP gymnastics. No open ports. No VPN checklist. + The interactive installer pulls rolling binaries. It wires Linux systemd and the docker group for the server, or installs a desktop client.

        @@ -488,5 +964,6 @@ curl -fsSL https://install.peardock.boats | bash -s -- --client --yes
        + diff --git a/learn/index.html b/learn/index.html index 50f0e14..76df0ce 100644 --- a/learn/index.html +++ b/learn/index.html @@ -540,7 +540,7 @@ flowchart TB
        Public key vs SERVER_SEED -

        Both are written to /opt/peardock/.env 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.

        +

        Both are written to /opt/peardock/.env 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.

        pd1. invite diff --git a/server.mjs b/server.mjs index 0169767..a2c7304 100644 --- a/server.mjs +++ b/server.mjs @@ -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). * @see https://www.sitemaps.org/protocol.html * @see https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap @@ -275,7 +275,7 @@ function buildSitemapXml() { const existing = byPath.get(urlPath) 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) byPath.set(urlPath, { loc, @@ -291,7 +291,7 @@ function buildSitemapXml() { 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 = [ '', '', diff --git a/src/js/home-demos.js b/src/js/home-demos.js new file mode 100644 index 0000000..8a7d43f --- /dev/null +++ b/src/js/home-demos.js @@ -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 = `${t}${ev.action}${ev.target}` + 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 = + '' + } + } else if (st === 'success') { + ico.innerHTML = '' + } else { + ico.innerHTML = '' + } + } + 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) => ``) + .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 = `${ts}${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 = ` + ${escapeHtml(L.shortId)} +
        + ${escapeHtml(L.meta)}` + } 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 `` +} +function emptyLayerInner() { + return `
        ` +} +function escapeHtml(s) { + return String(s) + .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 = `${t} ${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() +}) diff --git a/src/js/site.js b/src/js/site.js index cfe8d0e..36f02dc 100644 --- a/src/js/site.js +++ b/src/js/site.js @@ -41,6 +41,7 @@ function injectChrome() {