pages › Deployment · version 11 ·

Open the page

---
description: Deployment, the boxes as defined in CloudDeployment's deployment fence, each against its live status, with the actions the server offers and the chores; a signed base d2 page the designer keeps (P-1427, from Page:Mockup-deploy v2; razie 2026-10-13)
tags: page, deployment
---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>Deployment</title>
<style>
:root{--paper:#fbfaf7;--card:#fff;--ink:#1d1d1b;--soft:#6b6a66;--line:#e4e1da;--accent:#9a4b12;--accent-bg:#f5e8dc;--slate-bg:#f3f1ec;--ok:#2e8b57;--amber:#d99a00;--rose:#b23a48;--ai:#7a5af0;--body:system-ui,-apple-system,'Segoe UI',sans-serif;--display:'Manrope',system-ui,sans-serif}
@media (prefers-color-scheme:dark){:root:not(.lt){--paper:#141413;--card:#1d1d1b;--ink:#ecebe6;--soft:#9c9a93;--line:#33322f;--accent:#f0a35e;--accent-bg:#2e2419;--slate-bg:#22211f;--ok:#6cc08a;--amber:#f0c04a;--rose:#f08a92;--ai:#a993ff}}
:root.dk{--paper:#141413;--card:#1d1d1b;--ink:#ecebe6;--soft:#9c9a93;--line:#33322f;--accent:#f0a35e;--accent-bg:#2e2419;--slate-bg:#22211f;--ok:#6cc08a;--amber:#f0c04a;--rose:#f08a92;--ai:#a993ff}
.dp{--fz:1;color:var(--ink);font:15px/1.5 var(--body);max-width:1180px;margin:0 auto;padding:0 0 48px}
.dp *{box-sizing:border-box}
.dp h1{font:800 30px/1.15 var(--display);margin:6px 0 2px}
.dp .sub{color:var(--soft);font-size:14px;margin:0 0 14px;display:flex;gap:14px;flex-wrap:wrap}
.dp .sub a,.dp a.lk{color:var(--accent);font-weight:600;text-decoration:none}
.dp h2{font:700 13px var(--body);color:var(--soft);text-transform:uppercase;letter-spacing:.05em;margin:22px 0 8px}
.dp .banner{border:1px solid var(--amber);background:color-mix(in srgb,var(--amber) 9%,var(--card));border-radius:12px;padding:9px 14px;font-size:14px;margin:0 0 12px}
.dp .banner.err{border-color:var(--rose);background:color-mix(in srgb,var(--rose) 7%,var(--card))}
.dp .rel{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 14px}
.dp .rel b{font-family:var(--display)}.dp .soft{color:var(--soft);font-size:13px}
.dp .tabs{display:flex;gap:4px;margin:18px 0 8px;border-bottom:1px solid var(--line)}
.dp .tabs button{border:0;background:none;padding:7px 12px;font:600 14px var(--body);color:var(--soft);cursor:pointer;border-bottom:2px solid transparent}
.dp .tabs button.on{color:var(--ink);border-bottom-color:var(--accent)}
.dp .diag{display:flex;flex-wrap:wrap;align-items:stretch;gap:14px}
.dp .box{flex:1 1 300px;max-width:400px;background:var(--card);border:1.5px solid var(--line);border-radius:14px;padding:12px 14px;cursor:pointer;transition:border-color .12s,box-shadow .12s;min-width:0}
.dp .box:hover{border-color:var(--soft)}
.dp .box.sel{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
.dp .bh{display:flex;align-items:center;gap:8px}
.dp .bh .nm{font:800 18px var(--display)}
.dp .dot{width:10px;height:10px;border-radius:50%;flex:none;background:var(--line)}.dp .dot.ok{background:var(--ok)}.dp .dot.warn{background:var(--amber)}.dp .dot.bad{background:var(--rose)}
.dp .role{margin-left:auto;font:700 11px var(--body);text-transform:uppercase;letter-spacing:.04em;border-radius:999px;padding:1px 8px;border:1px solid var(--line);color:var(--soft)}
.dp .role.primary{color:var(--ok);border-color:var(--ok)}.dp .role.copy{color:var(--ai);border-color:var(--ai)}
.dp .host{font:12px ui-monospace,monospace;color:var(--soft);margin:1px 0 8px;overflow-wrap:anywhere}
.dp .kv{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:13px;margin:0}.dp .kv dt{color:var(--soft)}.dp .kv dd{margin:0;min-width:0;overflow-wrap:anywhere}
.dp .old{color:var(--rose);font-weight:700}
.dp .bars{margin-top:8px;display:flex;flex-direction:column;gap:5px}
.dp .bar{font-size:12px;color:var(--soft);display:grid;grid-template-columns:56px 1fr 60px;align-items:center;gap:6px}
.dp .bar i{display:block;height:6px;border-radius:999px;background:var(--slate-bg);overflow:hidden}.dp .bar i b{display:block;height:100%;background:var(--ok)}
.dp .bar i b.w{background:var(--amber)}.dp .bar i b.r{background:var(--rose)}
.dp .alerts{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
.dp .al{font-size:12px;border-radius:999px;padding:1px 8px;border:1px solid var(--line)}.dp .al.r{border-color:var(--rose);color:var(--rose)}.dp .al.a{border-color:var(--amber);color:var(--amber)}.dp .al.g{color:var(--ok);border-color:var(--ok)}
.dp .drift{margin-top:8px;font-size:12px;border-top:1px dashed var(--line);padding-top:6px;display:flex;flex-direction:column;gap:2px}
.dp .drift .d{color:var(--rose)}.dp .drift .k{color:var(--ok)}.dp .drift .u{color:var(--soft)}
.dp .act{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr);gap:16px;margin-top:4px}
.dp .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 14px;min-width:0}
.dp .acts{display:flex;flex-direction:column;gap:6px}
.dp .ab{display:flex;align-items:center;gap:10px;border:1px solid var(--line);background:var(--paper);border-radius:10px;padding:8px 10px;cursor:pointer;text-align:left;font:inherit;color:inherit;width:100%}
.dp .ab:hover{border-color:var(--soft)}.dp .ab.on{border-color:var(--accent);background:var(--accent-bg)}
.dp .ab .ic{font-size:20px;width:26px;text-align:center;flex:none}.dp .ab .t{font-weight:600}.dp .ab .d{font-size:12px;color:var(--soft)}
.dp .ab[disabled]{opacity:.5;cursor:not-allowed}.dp .ab .why{font-size:11px;color:var(--rose)}
.dp .form h3{font:800 18px var(--display);margin:0 0 4px}
.dp .lead{color:var(--soft);font-size:13px;margin:0 0 10px}
.dp .need{list-style:none;margin:0 0 12px;padding:0;display:flex;flex-direction:column;gap:6px}
.dp .need li{display:flex;gap:8px;align-items:flex-start;font-size:14px}.dp .need .ck{width:20px;text-align:center;flex:none}
.dp .fld{display:flex;flex-direction:column;gap:3px;margin:0 0 10px}
.dp .fld label{font-size:12px;font-weight:700;color:var(--soft)}
.dp .fld input,.dp .fld select{font:16px var(--body);border:1px solid var(--line);border-radius:8px;padding:7px 9px;background:var(--paper);color:var(--ink)}
.dp .fld .hint{font-size:12px;color:var(--soft)}
.dp .force{border:1px solid var(--rose);background:color-mix(in srgb,var(--rose) 7%,var(--card));border-radius:10px;padding:8px 10px;margin:0 0 10px;font-size:13px}
.dp .steps{font-size:13px;color:var(--soft);margin:0 0 12px;padding-left:18px}
.dp .row{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}
.dp .btn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:6px 12px;font:600 14px var(--body);cursor:pointer}
.dp .btn.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.dp .btn.danger{background:var(--rose);color:#fff;border-color:var(--rose)}
.dp .btn[disabled]{opacity:.45;cursor:not-allowed}
.dp .log{font:12px/1.5 ui-monospace,monospace;background:var(--slate-bg);border-radius:8px;padding:8px 10px;margin-top:10px;white-space:pre-wrap;max-height:260px;overflow:auto}
.dp .def{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:16px}
/* a plain panel, not a text box: the iPad's keyboard, zoom and autocorrect leave it alone; edit it in the topic (razie, 2026-10-13) */.dp .def pre.fence{margin:0;width:100%;min-height:200px;max-height:70vh;overflow:auto;font:13px/1.5 ui-monospace,monospace;border:1px solid var(--line);border-radius:10px;padding:10px 12px;background:var(--paper);color:var(--ink);white-space:pre;-webkit-user-select:text;user-select:text}
.dp .pok{color:var(--ok);font-size:13px;margin:6px 0 0}.dp .perr{color:var(--rose);font-size:13px;margin:6px 0 0}
.dp .who{font-size:13px;margin:0;padding-left:18px}.dp .who li{margin:0 0 6px}
.dp .first{border:1px solid var(--ai);background:color-mix(in srgb,var(--ai) 7%,var(--card));border-radius:12px;padding:10px 14px;font-size:14px;margin:0 0 12px}
.dp .first b{color:var(--ai)}
.dp .chores{columns:2;column-gap:12px}
.dp .th{break-inside:avoid;margin:0 0 12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px;box-shadow:inset 3px 0 0 var(--amber);display:block;color:inherit;text-decoration:none}
.dp .th.done{box-shadow:inset 3px 0 0 var(--ok);opacity:.75}
.dp .th .tt{font-weight:600}
.dp .th .bd{font-size:13px;color:var(--soft);margin:4px 0 6px;white-space:pre-wrap;overflow-wrap:anywhere}
.dp .th .tags{display:flex;gap:6px;flex-wrap:wrap;font-size:12px;color:var(--accent);font-weight:600}
.dp .note{font-size:12px;color:var(--soft);margin-top:4px}
.dp .who2{font-size:11px;font-weight:700;border-radius:999px;padding:0 7px;border:1px solid var(--line);white-space:nowrap}.dp .who2.ops{color:var(--ai);border-color:var(--ai)}.dp .who2.ask{color:var(--amber);border-color:var(--amber)}.dp .who2.me{color:var(--soft)}
.dp .steps2{list-style:none;margin:6px 0 8px;padding:0;display:flex;flex-direction:column;gap:4px}
.dp .st .sl{font-size:14px;color:var(--soft)}.dp .st.cur .sl{color:var(--ink);font-weight:600}.dp .st.past .sl{text-decoration:line-through;text-decoration-color:var(--line)}
.dp .st .sd{margin:6px 0 4px 24px;display:flex;flex-direction:column;gap:6px;font-size:13px}
.dp .cp{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.dp .cp code{font-size:13px;padding:4px 8px;overflow-wrap:anywhere;white-space:pre-wrap;flex:1 1 200px}
.dp .chores{columns:1}

.dp code{font:12px ui-monospace,monospace;background:var(--slate-bg);border-radius:5px;padding:1px 5px}
.dp [hidden]{display:none!important}
@media (max-width:900px){.dp .box{max-width:none}.dp .act,.dp .def{grid-template-columns:1fr}.dp .chores{columns:1}}
</style></head><body>
<div class="dp" id="dp">
<h1>🚀 Deployment</h1>
<p class="sub"><span>The deployment as defined in <a id="dp-deflink" href="/topics/digital-ocean-env#deployment">the boxes runbook</a>, each box against it, and what you can do, from here.</span><a href="/pipeline/P-1404">📋 the cloud5 checklist</a><a href="/razadmin/audit?critical=1">🔔 critical events</a></p>
<div id="dp-banner"></div>
<div class="rel" id="dp-rel"><span class="soft">Loading…</span></div>
<div class="tabs" role="tablist"><button class="on" data-tab="box" role="tab">🖥️ Boxes <span id="dp-ndrift" style="color:var(--rose)"></span></button><button data-tab="def" role="tab">📝 Definition</button></div>
<div id="dp-vbox">
  <div class="diag" id="dp-diag"></div>
  <p class="note">Each box is drawn from its block in the definition, then compared with what its admin reports. A difference is a <b>drift</b>, with the action that fixes it.</p>
  <h2 id="dp-acth">Actions</h2>
  <div class="act"><div class="card acts" id="dp-acts"></div><div class="card form" id="dp-form"></div></div>
</div>
<div id="dp-vdef" hidden>
  <div class="first" id="dp-first" hidden><b>No definition yet.</b> <b>💬 Define it together</b> opens a chat with the designer, who reads each box and its runbooks and drafts the <code>deployment</code> fence with you, box by box.</div>
  <div class="def">
    <div><pre id="dp-fence" class="fence" tabindex="0" aria-label="The deployment definition"></pre><p id="dp-parse" class="pok"></p></div>
    <div class="card"><h3 style="font:800 17px var(--display);margin:0 0 6px">One definition, read by all</h3>
      <ul class="who">
        <li><b>This page</b> draws the boxes from it and asks each one's admin how it really is.</li>
        <li><b>The builders</b> (the dispatcher, <code>deploy.sh</code>) take a box's address and token from it.</li>
        <li><b>A box's admin</b> reads its own block: its role, what it copies, when it pulls.</li>
        <li><b>Runbooks</b> it names (<code>runbook &lt;topic&gt;</code>) hold the chores, as journeys in a <code>chores</code> fence: how to do things to these boxes.</li>
        <li><b>Secrets are vault names</b> (<code>vault:&lt;name&gt;</code>), never values; 🔐 Sync secrets sends them from your vault.</li>
      </ul>
      <p class="note">Lines: <code>box &lt;name&gt; primary|copy of &lt;box&gt;|test {</code> … <code>}</code>; inside: <code>host</code>, <code>ip</code>, <code>droplet "…"</code>, <code>release latest|&lt;version&gt;</code>, <code>admin vault:&lt;name&gt;</code>, <code>env KEY vault:&lt;name&gt;</code> (a secret), <code>set KEY value</code> (a plain setting), <code>mongo</code>, <code>cdn</code>, <code>daily HH:MM</code>, <code>alert disk 80% memory 85% mongo 20GB</code>, <code>act &lt;role&gt;</code>, <code>adminurl</code>, <code>suite</code> (the box suite's URL), <code>note "…"</code>. Outside a box: <code>runbook &lt;topic&gt;</code>, <code>order &lt;box&gt; …</code> (a release's order). In a runbook's <code>chores</code> fence: <code>chore &lt;box&gt; "…" {</code> its steps <code>}</code>, each <code>step "…" [at &lt;url&gt;] [on &lt;box&gt;] [by ops|ask|me] [copy "…" | copy '…']</code> or <code>check dns|vault|health …</code>.</p>
      <div class="row" style="margin-top:10px"><button class="btn" id="dp-together">💬 Define it together</button><a class="btn primary" id="dp-edit" href="/topics/digital-ocean-env?edit=1" style="text-decoration:none">✏️ Edit in the runbook</a></div></div>
  </div>
</div>
<h2>Chores <span style="font-weight:400;text-transform:none;letter-spacing:0">· your thoughts tagged <code>chore</code>, the hand steps no button can do</span></h2>
<p class="note" id="dp-rbs"></p><div class="chores" id="dp-chores"><p class="note">Loading…</p></div>
<p class="note">Each chore is a journey from a runbook the definition names: one step at a time, where to go and what to paste. Your place in each is kept. 🤖 <b>ops</b> steps are the ops agent's (on your word, through the monitor), 🙋 <b>you</b> steps are yours; the runbooks are what's agreed. An action that needs a chore waits until it's ✅.</p>
</div>
<script>
(()=>{
const D2=window.d2||{},$=id=>document.getElementById(id);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(m,u,b)=>D2.api?D2.api(m,u,b):Promise.reject(new Error("E_BRIDGE: this page runs inside d2"));
const errText=e=>String(e&&e.message||e);
const missing=e=>/E_NOT_FOUND|no route|404/.test(errText(e));

// ---- the fence: parsed here for the Definition tab, and to draw the boxes until the server's /api/v2/deploy is live (P-1427) ----
const KNOWN=new Set(["host","ip","droplet","adminurl","suite","note","release","admin","env","set","mongo","cdn","daily","alert","act"]);
// a chore's step: step "<what>" [at <url>] [on <box>] [copy "<text>"]; or check <kind> <args…> (the server verifies it)
function stepOf(l,n){let m;
 if((m=/^check\s+(dns|vault|health)\s+(.+)$/.exec(l)))return{check:m[1],args:m[2].trim().split(/\s+/),line:n};
 if(!(m=/^step\s+"([^"]+)"(.*)$/.exec(l)))return{err:`line ${n}: a chore takes step "…" or check dns|vault|health …`};
 const st={text:m[1],line:n};let r=m[2].trim(),x;
 while(r){if((x=/^at\s+(\S+)\s*/.exec(r))){st.at=x[1];r=r.slice(x[0].length);continue}
  if((x=/^on\s+([a-z0-9-]+)\s*/i.exec(r))){st.on=x[1];r=r.slice(x[0].length);continue}
  if((x=/^by\s+(ops|ask|me)\s*/i.exec(r))){st.by=x[1].toLowerCase();r=r.slice(x[0].length);continue}
  if((x=/^copy\s+(?:"([^"]*)"|'([^']*)')\s*/.exec(r))){st.copy=x[1]!=null?x[1]:x[2];r=r.slice(x[0].length);continue}
  return{err:`line ${n}: after the step's text: at <url>, on <box>, by ops|ask|me or copy "…"`}}
 return st}
// a line without its comment: # outside quotes, at the line's start or after a space (the server's stripComment, P-1427)
function stripComment(line){let q="";for(let i=0;i<line.length;i++){const c=line[i];if(q){if(c===q)q="";continue}if(c==='"'||c==="'"){q=c;continue}if(c==="#"&&(i===0||/\s/.test(line[i-1])))return line.slice(0,i)}return line}
function fenceOf(text){const m=/```deployment[^\n]*\n([\s\S]*?)```/.exec(text||"");if(!m)return null;const head=/```(deployment[^\n]*)/.exec(text)[1];return head+"\n"+m[1]}
function parse(src){const out={id:"",name:"",boxes:[],chores:[],runbooks:[],order:[]},lines=String(src||"").split("\n");let box=null,open=false,ch=null;
 for(let i=0;i<lines.length;i++){const raw=lines[i],l=stripComment(raw).trim(),n=i+1;if(!l)continue;
  let m;
  if(!open){m=/^(?:```)?deployment\s+(\S+)(?:\s+"([^"]*)")?\s*\{$/.exec(l);if(!m)return{err:`line ${n}: expected deployment <id> "<name>" {`};out.id=m[1];out.name=m[2]||m[1];open=true;continue}
  if(ch){if(l==="}"){if(!ch.steps.length)return{err:`chore "${ch.text}" (line ${ch.line}) has no steps`};out.chores.push(ch);ch=null;continue}
   const st=stepOf(l,n);if(st.err)return st;ch.steps.push(st);continue}
  if(l==="}"){if(box){out.boxes.push(box);box=null;continue}open=false;continue}
  if(!box){
   if((m=/^box\s+([a-z0-9-]+)\s+(primary|test|copy\s+of\s+([a-z0-9-]+))\s*\{$/i.exec(l))){box={name:m[1],role:m[3]?"copy":m[2].toLowerCase(),copyOf:m[3]||"",host:[],env:{},set:{},alert:{},line:n};continue}
   if((m=/^chore\s+([a-z0-9-]+)\s+"([^"]+)"(.*?)(\{)?$/i.exec(l))){const c={box:m[1],text:m[2],codes:m[3].trim(),line:n,steps:[]};if(m[4])ch=c;else out.chores.push(c);continue}
   if((m=/^runbook\s+(\S+)$/i.exec(l))){out.runbooks.push(m[1]);continue}
   if((m=/^order\s+(.+)$/i.exec(l))){out.order=m[1].trim().split(/\s+/);continue}
   return{err:`line ${n}: expected box …, runbook …, chore … or }`}}
  const w=l.split(/\s+/),k=w[0].toLowerCase(),rest=l.slice(w[0].length).trim();
  if(!KNOWN.has(k))return{err:`line ${n}: unknown property "${w[0]}" in box ${box.name}`};
  if(k==="host")box.host.push(...rest.split(/\s+/));
  else if(k==="ip"){if(!/^[\d.:a-f]+$/i.test(rest))return{err:`line ${n}: ip takes an address`};box.ip=rest}
  else if(k==="env"){const e=/^(\w+)\s+vault:([a-z0-9-]+)$/.exec(rest);if(!e)return{err:`line ${n}: env takes KEY vault:<name>, never a value`};box.env[e[1]]=e[2]}
  else if(k==="set"){const e=/^(\w+)\s+(\S+)$/.exec(rest);if(!e)return{err:`line ${n}: set takes KEY value (a setting, not a secret)`};box.set[e[1]]=e[2]}
  else if(k==="admin"){const e=/^vault:([a-z0-9-]+)$/.exec(rest);if(!e)return{err:`line ${n}: admin takes vault:<name>, never a token`};box.admin=e[1]}
  else if(k==="alert"){const ws=rest.split(/\s+/);if(ws.length%2)return{err:`line ${n}: alert takes pairs: disk 80% memory 85%`};for(let j=0;j<ws.length;j+=2)box.alert[ws[j]]=ws[j+1]}
  else if(k==="daily"){if(!/^\d\d:\d\d$/.test(rest))return{err:`line ${n}: daily takes HH:MM`};box.daily=rest}
  else box[k]=rest.replace(/^"|"$/g,"")}
 if(box)return{err:`box ${box.name} (line ${box.line}) has no closing }`};if(open)return{err:"the fence has no closing }"};
 const names=new Set(out.boxes.map(b=>b.name));for(const b of out.boxes)if(b.copyOf&&!names.has(b.copyOf))return{err:`line ${b.line}: ${b.name} copies ${b.copyOf}, which isn't a box here`};
 return out}

// ---- state ----
const SOURCES=["CloudDeployment"];let SRC=SOURCES[0],RB=[],SRV=null,DEF=null,TOPIC="",LOCAL=null,BOX="",ACTS=null,ACT=null,RUN=0;
const pct=(v,lim)=>{const p=Math.max(0,Math.min(100,Math.round(v)));const L=parseFloat(lim)||85;return `<b class="${p>=L?"r":p>=L-15?"w":""}" style="width:${p}%"></b>`};
function boxes(){ // the server's view when it answers, else the definition plus this box's own health
 if(SRV)return SRV.boxes||[];
 return (DEF&&!DEF.err?DEF.boxes:[]).map(b=>{const here=LOCAL&&b.host.some(h=>h===location.hostname||location.hostname.endsWith("."+h)||h.endsWith(location.hostname));
  return Object.assign({},b,{live:here?{reachable:true,version:LOCAL.version,deployedAt:LOCAL.deployedAt,ok:LOCAL.ok,heap:LOCAL.heap}:null,drifts:here?driftsOf(b,LOCAL):[{what:"live status: needs the server (P-1427)",kind:"u"}]})})}
function driftsOf(b,live){const d=[];if(b.release&&b.release!=="latest"&&live.version!==b.release)d.push({what:`release: defined ${b.release}, runs ${live.version}`,fix:"deploy"});return d}
function card(b){const lv=b.live||{},dr=b.drifts||[],bad=lv.reachable===false||lv.ok===false,real=dr.filter(x=>x.kind!=="u");
 const dot=lv.reachable==null&&!b.live?"":bad?"bad":real.length?"warn":"ok";
 const rel=lv.version?`${esc(lv.version)}${b.release&&b.release!=="latest"&&b.release!==lv.version?"":" "}`:"—";
 const bar=(n,v,lim,txt)=>v==null?"":`<div class="bar">${n}<i>${pct(v,lim)}</i>${esc(txt!=null?txt:Math.round(v)+"%")}</div>`;
 const heap=lv.heap&&lv.heap.limitMB?100*lv.heap.usedMB/lv.heap.limitMB:null;
 return `<div class="box${b.name===BOX?" sel":""}" data-box="${esc(b.name)}" role="button" tabindex="0">
  <div class="bh"><span class="dot ${dot}"></span><span class="nm">${esc(b.name)}</span><span class="role ${esc(b.role)}">${esc(b.role)}${b.copyOf?" of "+esc(b.copyOf):""}</span></div>
  <div class="host">${esc((b.host||[]).join(" · ")||"no host")}</div>
  <dl class="kv"><dt>Release</dt><dd>${rel} <span class="soft">(defined ${esc(b.release||"—")})</span></dd>
  <dt>Status</dt><dd class="${bad?"old":""}">${lv.reachable===false?"unreachable":lv.ok===false?"unhealthy":lv.version?"up"+(lv.deployedAt?" · deployed "+esc(new Date(lv.deployedAt).toLocaleString()):""):"not known yet"}</dd>
  ${b.ip?`<dt>IP</dt><dd><code>${esc(b.ip)}</code></dd>`:""}${b.droplet?`<dt>Droplet</dt><dd>${esc(b.droplet)}</dd>`:""}${b.copyOf?`<dt>Daily pull</dt><dd>${esc(b.daily||"—")}</dd>`:""}
  ${b.admin?`<dt>Admin</dt><dd>${b.adminurl?`<code>${esc(b.adminurl)}</code> · `:""}<code>vault:${esc(b.admin)}</code></dd>`:""}${b.suite?`<dt>Box suite</dt><dd><code>${esc(b.suite)}</code></dd>`:""}</dl>${b.note?`<div class="force" style="margin:8px 0 0;font-size:12px">⚠️ ${esc(b.note)}</div>`:""}
  <div class="bars">${bar("Disk",lv.disk,b.alert&&b.alert.disk)}${bar("Memory",lv.memory,b.alert&&b.alert.memory)}${bar("Heap",heap,85,lv.heap?lv.heap.usedMB+" MB":null)}</div>
  <div class="alerts">${(lv.alerts||[]).map(a=>`<span class="al ${a.level==="red"?"r":"a"}">${esc(a.code||a.text)}</span>`).join("")}${Object.keys(b.env||{}).length?`<span class="al g">🔐 ${Object.keys(b.env).length} secret${Object.keys(b.env).length>1?"s":""} from the vault</span>`:""}</div>
  <div class="drift">${dr.length?dr.map(x=>`<span class="${x.kind==="u"?"u":"d"}">${x.kind==="u"?"…":"≠"} ${esc(x.what)}${x.fix?` → ${esc(x.fix)}`:""}</span>`).join(""):`<span class="k">✓ as defined</span>`}</div></div>`}
function drawBoxes(q){const bs=boxes();if(!BOX&&bs.length)BOX=bs[0].name;
 $("dp-diag").innerHTML=bs.length?bs.map(card).join(""):`<div class="card" style="flex:1"><p class="lead" style="margin:0">No boxes defined yet. Open <b>📝 Definition</b> and <b>💬 Define it together</b>.</p></div>`;
 const n=bs.reduce((a,b)=>a+(b.drifts||[]).filter(x=>x.kind!=="u").length,0);$("dp-ndrift").textContent=n?`· ${n} drift${n>1?"s":""}`:"";if(!q)drawActs()}
function drawRel(){const r=SRV&&SRV.release||(LOCAL?{version:LOCAL.version,commit:LOCAL.commit,at:LOCAL.deployedAt}:null);
 if(!r){$("dp-rel").innerHTML=`<span class="soft">Release unknown</span>`;return}
 const bs=boxes();$("dp-rel").innerHTML=`<span>📦 Latest release <b>${esc(r.version)}</b> <span class="soft">${r.commit?"commit "+esc(String(r.commit).slice(0,8)):""}${r.at?" · "+esc(new Date(r.at).toLocaleString()):""}</span></span>${DEF&&DEF.order&&DEF.order.length?`<span class="soft">· order ${DEF.order.map(esc).join(" → ")}</span>`:""}${bs.length?`<span class="soft">· ${bs.map(b=>`${esc(b.name)} ${b.live&&b.live.version?(b.live.version===r.version?"✓":"✗ ("+esc(b.live.version)+")"):"?"}`).join(" · ")}</span>`:""}`}

// ---- actions: described by the server (P-1427 §2); the page only draws them ----
async function loadActs(){ACTS=null;ACT=null;if(!BOX)return drawActs();if(!SRV){drawActs();return}
 try{const r=await api("GET","/api/v2/deploy/"+encodeURIComponent(BOX)+"/actions");ACTS=r.actions||[]}catch(e){ACTS={err:errText(e)}}drawActs()}
function drawActs(){$("dp-acth").textContent="Actions"+(BOX?" · "+BOX:"");const a=$("dp-acts"),f=$("dp-form");
 if(!SRV){a.innerHTML=`<p class="lead" style="margin:0">Actions come from the server, which checks each one: they arrive with <a class="lk" href="/pipeline/P-1427">P-1427</a>. Until then nothing here can change a box.</p>`;f.innerHTML=`<p class="lead" style="margin:0">Deploy, 📥 Copy (🔨 only the first time onto a box that can't show it's a copy), 🔐 Sync secrets, ⏰ Daily pull, restart, roll back, become primary.</p>`;return}
 if(!ACTS){a.innerHTML=`<p class="lead">Loading…</p>`;f.innerHTML="";return}
 if(ACTS.err){a.innerHTML=`<p class="lead" style="color:var(--rose)">${esc(ACTS.err)}</p>`;f.innerHTML="";return}
 a.innerHTML=ACTS.map(x=>`<button class="ab${ACT&&x.id===ACT.id?" on":""}" data-a="${esc(x.id)}"${x.enabled===false?" disabled":""}><span class="ic">${esc(x.icon||"⚙️")}</span><span><span class="t">${esc(x.label)}</span>${x.detail?`<br><span class="d">${esc(x.detail)}</span>`:""}${x.enabled===false&&x.why?`<br><span class="why">not now: ${esc(x.why)}</span>`:""}</span></button>`).join("")||`<p class="lead">No actions for ${esc(BOX)}.</p>`;
 drawForm()}
function drawForm(){const f=$("dp-form");if(!ACT){f.innerHTML=`<p class="lead" style="margin:0">Pick an action: its form shows what it needs and you start it here. Progress shows below it, and it lands in the audit.</p>`;return}
 const x=ACT,blocked=(x.needs||[]).some(n=>n.ok===false);
 const fld=d=>{const id="dp-f-"+esc(d.name),lab=`<label for="${id}">${esc(d.label||d.name)}</label>`,h=d.hint?`<span class="hint">${esc(d.hint)}</span>`:"";
  if(d.kind==="select")return `<div class="fld">${lab}<select id="${id}" data-f="${esc(d.name)}">${(d.options||[]).map(o=>`<option value="${esc(o.value!=null?o.value:o)}">${esc(o.label||o)}</option>`).join("")}</select>${h}</div>`;
  return `<div class="fld">${lab}<input id="${id}" data-f="${esc(d.name)}" type="${d.kind==="password"?"password":"text"}" autocomplete="off" placeholder="${esc(d.placeholder||"")}" value="${esc(d.value||"")}">${h}</div>`};
 f.innerHTML=`<h3>${esc(x.icon||"")} ${esc(x.label)}</h3>${x.lead?`<p class="lead">${esc(x.lead)}</p>`:""}<ul class="need">${(x.needs||[]).map(n=>`<li><span class="ck">${n.ok===false?"⛔":n.ok==="hammer"?"🔨":"✅"}</span><span>${esc(n.text)}</span></li>`).join("")}</ul>${x.force?`<div class="force"><b style="color:var(--rose)">Force:</b> ${esc(x.force)}</div>`:""}${(x.fields||[]).map(fld).join("")}${(x.steps||[]).length?`<ol class="steps">${x.steps.map(s=>`<li>${esc(s)}</li>`).join("")}</ol>`:""}<div class="row">${blocked?`<span class="note">Waits on ⛔ above</span>`:""}<button class="btn" data-x>Cancel</button><button class="btn ${x.force?"danger":"primary"}" data-go${blocked?" disabled":""}>${esc(x.go||"Start")}</button></div><div class="log" id="dp-log" hidden></div>`}
async function go(){const x=ACT,body={};document.querySelectorAll("#dp-form [data-f]").forEach(el=>{body[el.dataset.f]=el.value});
 const log=$("dp-log"),btn=document.querySelector("#dp-form [data-go]");log.hidden=false;log.textContent="starting…";btn.disabled=true;
 try{const r=await api("POST","/api/v2/deploy/"+encodeURIComponent(BOX)+"/"+encodeURIComponent(x.id),body);document.querySelectorAll('#dp-form input[type=password]').forEach(el=>{el.value=""});
  const id=r.run||r.id;if(!id){log.textContent=JSON.stringify(r,null,1);return}const me=++RUN;
  for(let i=0;i<600&&me===RUN;i++){const s=await api("GET","/api/v2/deploy/runs/"+encodeURIComponent(id));log.textContent=(s.lines||[]).join("\n")||"…";log.scrollTop=log.scrollHeight;if(s.done){log.textContent+=`\n${s.ok?"✅ done":"⛔ "+(s.error||"failed")}`;load(true);break}await new Promise(k=>setTimeout(k,2000))}}
 catch(e){document.querySelectorAll('#dp-form input[type=password]').forEach(el=>{el.value=""});log.textContent="⛔ "+errText(e);btn.disabled=false}}

// ---- chores: thoughts tagged chore ----
function rbLine(){const el=$("dp-rbs");if(!el)return;el.innerHTML=RB.length?"From the runbooks: "+RB.map(r=>`<a class="lk" href="/topics/${encodeURIComponent(r.name)}#journeys">${esc(r.name)}</a> (${r.err?`<span style="color:var(--rose)">${esc(r.err)}</span>`:r.chores+" chore"+(r.chores===1?"":"s")})`).join(", "):""}
async function loadChores(){rbLine();const el=$("dp-chores");let ts=[];try{const r=await api("GET","/api/v2/thoughts?tag=chore&limit=50");ts=r.data||[]}catch(e){el.innerHTML=`<p class="note">Chores didn't load: ${esc(errText(e))}</p>`;return}
 const fromDef=(DEF&&!DEF.err?DEF.chores:[]).filter(c=>!ts.some(t=>String(t.text||"").includes(c.text)));
 const done=t=>/(^|\s)#done\b|## Done ✓/.test(t.text||"")||t.state==="done"||t.state==="archived";
 const cards=ts.map(t=>{const n=String(t.name||"").replace(/^Thought:/,""),tx=String(t.text||"").replace(/#chore\b/g,"").trim(),first=tx.split("\n")[0],rest=tx.split("\n").slice(1).join("\n").split("\n## ")[0].trim();
  return {d:done(t),h:`<a class="th${done(t)?" done":""}" href="/thoughts/focus/${encodeURIComponent(n)}${t.project?"?project="+encodeURIComponent(t.project):""}"><div class="tt">${done(t)?"✅":"🧹"} ${esc(first.slice(0,140))}</div>${rest?`<div class="bd">${esc(rest.slice(0,400))}</div>`:""}<div class="tags">#chore${(tx.match(/#[\w-]+/g)||[]).slice(0,4).map(g=>" "+esc(g)).join("")}</div></a>`}});
 cards.sort((a,b)=>a.d-b.d);
 CH=fromDef;el.innerHTML=cards.map(c=>c.h).join("")+fromDef.map((c,i)=>runner(c,i)).join("")||`<p class="note">No chores. 🎉</p>`;fromDef.forEach((c,i)=>stepGet(c).then(k=>{c.at=k;const x=document.querySelector(`#dp [data-run="${i}"]`);if(x)x.outerHTML=runner(c,i)}))}
// ---- a chore is a journey: one step at a time, where to go, what to paste; progress kept per person ----
let CH=[];const SK=c=>"dp-chore:"+c.box+":"+c.text;
const stepGet=async c=>{try{if(D2.store){const v=await D2.store.get(SK(c));return +v||0}}catch(e){}try{return +localStorage.getItem(SK(c))||0}catch(e){return 0}};
const stepSet=async(c,k)=>{c.at=k;try{if(D2.store){await D2.store.set(SK(c),String(k));return}}catch(e){}try{localStorage.setItem(SK(c),String(k))}catch(e){}};
const CHK={dns:a=>`DNS: ${a[0]} answers ${a[1]}`,vault:a=>`your vault holds ${a.join(", ")}`,health:a=>`${a[0]} is up and healthy`};
function runner(c,i){const n=c.steps.length,k=Math.min(c.at||0,n),done=k>=n;
 const WHO={ops:`<span class="who2 ops" title="The ops agent does this without asking, on your word">🤖 ops</span>`,ask:`<span class="who2 ask" title="The ops agent asks you first">🤖 ops, asks you</span>`,me:`<span class="who2 me" title="Yours">🙋 you</span>`,check:`<span class="who2 ops" title="The server checks it">🔎 server</span>`};
 const li=(s,j)=>{const cur=j===k,past=j<k,label=(s.check?WHO.check+" "+esc(CHK[s.check](s.args)):(WHO[s.by||"me"])+" "+esc(s.text));
  const where=s.at?`<a class="lk" href="${esc(s.at)}" target="_blank" rel="noopener">📍 ${esc(s.at.replace(/^https?:\/\//,"").slice(0,60))}</a>`:s.on?`<span>🖥️ on ${esc(s.on)}</span>`:"";
  return `<li class="st${cur?" cur":""}${past?" past":""}"><div class="sl">${past?"✅":cur?"👉":"⬜"} ${label}</div>${cur?`<div class="sd">${where}${s.copy!=null?`<div class="cp"><code>${esc(s.copy)}</code><button class="btn" data-copy="${esc(s.copy)}">📋 Copy</button></div>`:""}${s.check?`<div class="note">The server checks this once <a class="lk" href="/pipeline/P-1427">P-1427</a> is live; until then, tick it yourself.</div>`:""}<div class="row" style="justify-content:flex-start">${j>0?`<button class="btn" data-back="${i}">↺ Back</button>`:""}<button class="btn primary" data-next="${i}">✓ Done${j<n-1?", next":""}</button></div></div>`:""}</li>`};
 const nOps=c.steps.filter(s=>s.by==="ops"||s.by==="ask"||s.check).length;
 return `<div class="th${done?" done":""}" data-run="${i}"><div class="tt">${done?"✅":"🧹"} ${esc(c.box)}: ${esc(c.text)} <span class="soft">· ${Math.min(k,n)}/${n}${nOps?` · 🤖 ${nOps} for ops and the server, 🙋 ${n-nOps} yours`:` · all yours`}</span></div>${done?`<div class="bd">Done. <button class="btn" data-reset="${i}">↺ Run again</button></div>`:`<ol class="steps2">${c.steps.map(li).join("")}</ol>`}<div class="tags">#chore #${esc(c.box)}${c.codes?" "+esc(c.codes):""}</div></div>`}

// ---- load ----
async function load(quiet){
 try{SRV=await api("GET","/api/v2/deploy");$("dp-banner").innerHTML=""}catch(e){SRV=null;
  $("dp-banner").innerHTML=missing(e)?`<div class="banner">The server side of this page (<a class="lk" href="/pipeline/P-1427">P-1427</a>) isn't live yet: showing the definition, and live status for this box only. Actions arrive with it.</div>`:/E_FORBIDDEN|E_AUTH/.test(errText(e))?`<div class="banner err">Deployment is for the Architect only.</div>`:`<div class="banner err">The deployment didn't load: ${esc(errText(e))}</div>`}
 if(SRV&&SRV.fence!=null)TOPIC=SRV.fence;else{TOPIC="";for(const nm of SOURCES){try{const t=await api("GET","/api/v2/topics/"+encodeURIComponent(nm)+"?format=json");TOPIC=fenceOf(typeof t==="string"?t:(t.text||t.body||""))||"";if(TOPIC){SRC=nm;break}}catch(e){}}}
 $("dp-deflink").textContent=SRC;$("dp-deflink").href="/topics/"+encodeURIComponent(SRC)+"#deployment";$("dp-edit").href="/topics/"+encodeURIComponent(SRC)+"?edit=1";$("dp-edit").textContent="✏️ Edit in "+SRC;
 if(!SRV){try{LOCAL=await api("GET","/api/v2/health")}catch(e){LOCAL=null}}
 DEF=TOPIC?parse(TOPIC):null;RB=[];
 if(DEF&&!DEF.err){for(const nm of DEF.runbooks){try{const t=await api("GET","/api/v2/topics/"+encodeURIComponent(nm)+"?format=json"),tx=typeof t==="string"?t:(t.text||t.body||"");
   const fs=[...tx.matchAll(/```chores[^\n]*\n([\s\S]*?)```/g)];let nc=0,err="";
   for(const f of fs){const r=parse("deployment "+nm+" {\n"+f[1]+"\n}");if(r.err){err=r.err;continue}for(const c of r.chores){c.runbook=nm;DEF.chores.push(c);nc++}}
   RB.push({name:nm,chores:nc,err:err||(fs.length?"":"no chores fence")})}catch(e){RB.push({name:nm,chores:0,err:errText(e)})}}}$("dp-fence").textContent=TOPIC||sample();
 showParse();$("dp-first").hidden=!!TOPIC;drawRel();drawBoxes(quiet);if(!quiet)loadActs();loadChores()}
function sample(){return '```deployment d2 "d2 cloud" {\n  box cloud4 primary {\n    host aiputty.com aidieselapps.com\n    release latest\n    admin vault:d2-admin-token4\n    alert disk 80% memory 85%\n    act god\n  }\n  box cloud5 copy of cloud4 {\n    host cloud5.aiputty.com\n    release latest\n    admin vault:d2-admin-token5\n    env D2_ENC_SECRET vault:d2-enc-secret\n    daily 03:30\n    act god\n  }\n}\n```'}
function showParse(){const v=$("dp-fence").textContent,p=parse((fenceOf(v)||v).replace(/^```/,"")),el=$("dp-parse");
 if(p.err){el.className="perr";el.textContent="⛔ "+p.err}else{el.className="pok";el.textContent=`✓ parses: ${p.boxes.length} box${p.boxes.length===1?"":"es"}${p.runbooks&&p.runbooks.length?`, chores from ${p.runbooks.join(", ")}`:`, ${p.chores.length} chore${p.chores.length===1?"":"s"}`}${TOPIC?"":" · a sample: nothing is saved yet"}`}}

document.addEventListener("click",e=>{
 const tb=e.target.closest("#dp [data-tab]");if(tb){document.querySelectorAll("#dp [data-tab]").forEach(x=>x.classList.toggle("on",x===tb));$("dp-vbox").hidden=tb.dataset.tab!=="box";$("dp-vdef").hidden=tb.dataset.tab!=="def";return}
 const b=e.target.closest("#dp .box[data-box]");if(b){BOX=b.dataset.box;document.querySelectorAll("#dp .box").forEach(x=>x.classList.toggle("sel",x===b));loadActs();return}
 const a=e.target.closest("#dp .ab");if(a&&!a.disabled){ACT=(ACTS||[]).find(x=>x.id===a.dataset.a)||null;RUN++;drawActs();return}
 if(e.target.closest("#dp [data-x]")){ACT=null;RUN++;drawActs();return}
 if(e.target.closest("#dp [data-go]")){go();return}
 const cp=e.target.closest("#dp [data-copy]");if(cp){const t=cp.dataset.copy,ok=()=>{cp.textContent="✓ Copied";setTimeout(()=>{cp.textContent="📋 Copy"},1500)};(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(ok,()=>{const r=document.createRange();const c=cp.previousElementSibling;r.selectNodeContents(c);const s=getSelection();s.removeAllRanges();s.addRange(r);cp.textContent="Selected: ⌘C"});return}
 const nx=e.target.closest("#dp [data-next],#dp [data-back],#dp [data-reset]");if(nx){const i=+(nx.dataset.next??nx.dataset.back??nx.dataset.reset),c=CH[i];if(!c)return;const k=nx.dataset.next!=null?(c.at||0)+1:nx.dataset.back!=null?Math.max(0,(c.at||0)-1):0;stepSet(c,k).then(()=>{const x=document.querySelector(`#dp [data-run="${i}"]`);if(x)x.outerHTML=runner(c,i)});return}
 if(e.target.closest("#dp-together")){const t="Let's work on the deployment definition in CloudDeployment together (Page:Deployment).";if(D2.chat)D2.chat({role:"designer",subject:"Define the deployment",text:t}).catch(()=>{});return}});
document.addEventListener("keydown",e=>{const b=e.target.closest&&e.target.closest("#dp .box[data-box]");if(b&&(e.key==="Enter"||e.key===" ")){e.preventDefault();b.click()}});
load();setInterval(()=>{if(!document.hidden)load(true)},60000);
})();
</script>
</body></html>