bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

launcher-ui.html (25024B)


      1 <!doctype html>
      2 <html>
      3   <head>
      4     <meta charset="utf-8" />
      5     <meta name="viewport" content="width=device-width, initial-scale=1" />
      6     <title>Bzl Launcher</title>
      7     <style>
      8       body {
      9         font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
     10         margin: 0;
     11         background: #0b0b10;
     12         color: #eee;
     13       }
     14       .wrap {
     15         max-width: 1000px;
     16         margin: 0 auto;
     17         padding: 18px;
     18       }
     19       .card {
     20         background: #141420;
     21         border: 1px solid rgba(255, 255, 255, 0.1);
     22         border-radius: 14px;
     23         padding: 14px;
     24         margin: 12px 0;
     25       }
     26       .row {
     27         display: flex;
     28         gap: 12px;
     29         flex-wrap: wrap;
     30         align-items: center;
     31       }
     32       .btn {
     33         border-radius: 999px;
     34         padding: 10px 14px;
     35         border: 1px solid rgba(255, 255, 255, 0.18);
     36         background: rgba(255, 255, 255, 0.06);
     37         color: #eee;
     38         cursor: pointer;
     39         font-weight: 700;
     40       }
     41       .btn.primary {
     42         background: linear-gradient(180deg, rgba(255, 140, 0, 0.95), rgba(255, 80, 160, 0.95));
     43         color: #140812;
     44         border: 0;
     45       }
     46       .btn.danger {
     47         background: rgba(255, 80, 120, 0.18);
     48         border-color: rgba(255, 80, 120, 0.35);
     49       }
     50       label {
     51         font-size: 12px;
     52         opacity: 0.85;
     53       }
     54       input {
     55         background: rgba(255, 255, 255, 0.08);
     56         color: #eee;
     57         border: 1px solid rgba(255, 255, 255, 0.14);
     58         border-radius: 10px;
     59         padding: 10px 12px;
     60         min-width: 160px;
     61       }
     62       .mono {
     63         font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
     64         font-size: 12px;
     65       }
     66       pre {
     67         background: #0f0f18;
     68         border: 1px solid rgba(255, 255, 255, 0.1);
     69         border-radius: 12px;
     70         padding: 12px;
     71         max-height: 340px;
     72         overflow: auto;
     73         white-space: pre-wrap;
     74       }
     75       .pill {
     76         display: inline-block;
     77         padding: 2px 10px;
     78         border-radius: 999px;
     79         border: 1px solid rgba(255, 255, 255, 0.14);
     80         background: rgba(255, 255, 255, 0.06);
     81         font-size: 12px;
     82       }
     83       .hint {
     84         opacity: 0.75;
     85         font-size: 12px;
     86         margin-top: 6px;
     87       }
     88     </style>
     89   </head>
     90   <body>
     91     <div class="wrap">
     92       <h2 style="margin: 6px 0 0 0">Bzl Launcher</h2>
     93       <div style="opacity: 0.8; margin-top: 6px">Local control panel for starting/stopping Bzl and an optional Cloudflare tunnel.</div>
     94 
     95       <div class="card">
     96         <div class="row" style="justify-content: space-between">
     97           <div>
     98             <div><b>Status</b> <span id="statusPill" class="pill">Checking…</span></div>
     99             <div class="mono" style="opacity: 0.85; margin-top: 6px" id="statusText"></div>
    100           </div>
    101           <div class="row">
    102             <button class="btn primary" id="btnStart">Start Bzl</button>
    103             <button class="btn danger" id="btnStop">Stop Bzl</button>
    104             <button class="btn" id="btnOpen">Open site</button>
    105           </div>
    106         </div>
    107       </div>
    108 
    109       <div class="card">
    110         <div class="row" style="justify-content: space-between">
    111           <div>
    112             <div><b>Settings</b></div>
    113             <div class="hint">Writes to <span class="mono">.env</span>. Bzl will read it on startup.</div>
    114           </div>
    115           <div class="row">
    116             <button class="btn" id="btnSave">Save</button>
    117             <button class="btn" id="btnReload">Reload</button>
    118           </div>
    119         </div>
    120         <div class="row" style="margin-top: 12px">
    121           <div>
    122             <label>PORT</label><br />
    123             <input id="inpPort" value="__PORT__" />
    124           </div>
    125           <div>
    126             <label>HOST</label><br />
    127             <input id="inpHost" value="__HOST__" />
    128           </div>
    129           <div>
    130             <label>REGISTRATION_CODE</label><br />
    131             <input id="inpReg" value="__REGISTRATION_CODE__" placeholder="(optional)" />
    132           </div>
    133           <div>
    134             <label>CLOUDFLARED_TUNNEL (named)</label><br />
    135             <input id="inpTunnel" value="__CLOUDFLARED_TUNNEL__" placeholder="(optional)" />
    136           </div>
    137           <div>
    138             <label>CLOUDFLARED_CONFIG (optional)</label><br />
    139             <input id="inpCfg" value="__CLOUDFLARED_CONFIG__" placeholder="%USERPROFILE%\\.cloudflared\\config.yml" />
    140           </div>
    141         </div>
    142       </div>
    143 
    144       <div class="card">
    145         <div class="row" style="justify-content: space-between">
    146           <div>
    147             <div><b>Updates</b> <span id="updPill" class="pill">Checking...</span></div>
    148             <div class="hint" id="updText">Checks GitHub releases for updates and preserves your <span class="mono">data/</span> and <span class="mono">.env</span>.</div>
    149             <div class="row" style="margin-top: 10px">
    150               <label style="display: flex; gap: 8px; align-items: center">
    151                 <input id="chkUpdEnable" type="checkbox" />
    152                 Enable updates (opt-in)
    153               </label>
    154               <select id="updSelect" style="flex: 1; min-width: 240px"></select>
    155               <button class="btn" id="btnUpdNotes">Release notes</button>
    156             </div>
    157           </div>
    158           <div class="row">
    159             <button class="btn" id="btnUpdCheck">Check</button>
    160             <button class="btn primary" id="btnUpdApply">Apply update</button>
    161           </div>
    162         </div>
    163       </div>
    164 
    165       <div class="card">
    166         <div class="row" style="justify-content: space-between">
    167           <div>
    168             <div><b>Cloudflare tunnel (optional)</b></div>
    169             <div class="hint">Quick tunnel prints a public URL in the log. Named tunnel requires <span class="mono">CLOUDFLARED_TUNNEL</span>.</div>
    170           </div>
    171           <div class="row">
    172             <button class="btn" id="btnTunnelQuick">Start quick tunnel</button>
    173             <button class="btn" id="btnTunnelNamed">Start named tunnel</button>
    174             <button class="btn danger" id="btnTunnelStop">Stop tunnel</button>
    175           </div>
    176         </div>
    177         <div class="hint" style="margin-top: 8px">
    178           Share link: <span class="mono" id="publicUrl">(waiting for tunnel...)</span>
    179           <button class="btn" id="btnCopyPublic" style="padding: 6px 10px; margin-left: 8px">Copy</button>
    180         </div>
    181       </div>
    182 
    183       <div class="card">
    184         <div class="row" style="justify-content: space-between">
    185           <div>
    186             <div><b>Named tunnel setup (choose your own domain)</b> <span id="cfPill" class="pill">Checking...</span></div>
    187             <div class="hint">Runs <span class="mono">cloudflared</span> setup steps for you. You still need a domain on Cloudflare.</div>
    188             <div class="hint" style="margin-top: 8px">
    189               Login link: <span class="mono" id="cfLoginUrl">(click Login)</span>
    190               <button class="btn" id="btnCopyLogin" style="padding: 6px 10px; margin-left: 8px">Copy</button>
    191             </div>
    192           </div>
    193           <div class="row">
    194             <button class="btn" id="btnCfRefresh">Refresh</button>
    195             <button class="btn danger" id="btnCfCancel">Cancel</button>
    196           </div>
    197         </div>
    198 
    199         <div class="row" style="margin-top: 12px">
    200           <div>
    201             <label>Tunnel name</label><br />
    202             <input id="inpCfName" list="cfTunnelNames" placeholder="bzl" />
    203             <datalist id="cfTunnelNames"></datalist>
    204           </div>
    205           <div style="flex: 1; min-width: 260px">
    206             <label>Hostname</label><br />
    207             <input id="inpCfHost" placeholder="bzl.example.com" style="width: 100%" />
    208           </div>
    209           <div style="flex: 1; min-width: 260px">
    210             <label>Credentials file</label><br />
    211             <input id="inpCfCreds" list="cfCredsList" placeholder="Pick from list..." style="width: 100%" />
    212             <datalist id="cfCredsList"></datalist>
    213             <div class="hint" id="cfCredsHint"></div>
    214           </div>
    215           <div style="flex: 1; min-width: 260px">
    216             <label>Config path</label><br />
    217             <input id="inpCfCfg" list="cfCfgList" placeholder="Pick from list..." style="width: 100%" />
    218             <datalist id="cfCfgList"></datalist>
    219           </div>
    220         </div>
    221 
    222         <div class="row" style="margin-top: 12px">
    223           <button class="btn primary" id="btnCfLogin">1) Login</button>
    224           <button class="btn" id="btnCfCreate">2) Create tunnel</button>
    225           <button class="btn" id="btnCfRoute">3) Route DNS</button>
    226           <button class="btn" id="btnCfWriteCfg">4) Write config.yml</button>
    227           <div class="hint" id="cfStatusText" style="margin-left: 6px"></div>
    228         </div>
    229       </div>
    230 
    231       <div class="card">
    232         <div class="row" style="justify-content: space-between">
    233           <div><b>Logs</b> <span class="pill mono">live</span></div>
    234           <button class="btn" id="btnClear">Clear</button>
    235         </div>
    236         <pre id="log" class="mono"></pre>
    237       </div>
    238     </div>
    239 
    240     <script>
    241       const API_TOKEN = "__API_TOKEN__";
    242       const logEl = document.getElementById("log");
    243       const statusPill = document.getElementById("statusPill");
    244       const statusText = document.getElementById("statusText");
    245       const cfPill = document.getElementById("cfPill");
    246       const cfStatusText = document.getElementById("cfStatusText");
    247       const inpPort = document.getElementById("inpPort");
    248       const inpHost = document.getElementById("inpHost");
    249       const inpReg = document.getElementById("inpReg");
    250       const inpTunnel = document.getElementById("inpTunnel");
    251       const inpCfg = document.getElementById("inpCfg");
    252       const inpCfName = document.getElementById("inpCfName");
    253       const inpCfHost = document.getElementById("inpCfHost");
    254       const inpCfCreds = document.getElementById("inpCfCreds");
    255       const inpCfCfg = document.getElementById("inpCfCfg");
    256       const cfCredsHint = document.getElementById("cfCredsHint");
    257       const publicUrlEl = document.getElementById("publicUrl");
    258       const updPill = document.getElementById("updPill");
    259       const updText = document.getElementById("updText");
    260       const cfLoginUrlEl = document.getElementById("cfLoginUrl");
    261       let cfTunnels = [];
    262       let cfCredsById = new Map();
    263       const updEnabledKey = "bzlLauncherUpdatesEnabled";
    264       const updSelect = document.getElementById("updSelect");
    265       const updNotes = document.getElementById("btnUpdNotes");
    266       const updEnable = document.getElementById("chkUpdEnable");
    267       let updReleases = [];
    268       let updSelected = null;
    269 
    270       function append(line) {
    271         logEl.textContent += line + "\n";
    272         logEl.scrollTop = logEl.scrollHeight;
    273       }
    274 
    275       function setPublicUrl(url) {
    276         if (!publicUrlEl) return;
    277         const u = String(url || "").trim();
    278         publicUrlEl.textContent = u || "(not available yet)";
    279         publicUrlEl.dataset.url = u;
    280       }
    281 
    282       async function copyText(text) {
    283         const t = String(text || "");
    284         if (!t) return false;
    285         try {
    286           await navigator.clipboard.writeText(t);
    287           return true;
    288         } catch {
    289           try {
    290             const ta = document.createElement("textarea");
    291             ta.value = t;
    292             ta.style.position = "fixed";
    293             ta.style.left = "-9999px";
    294             document.body.appendChild(ta);
    295             ta.focus();
    296             ta.select();
    297             const ok = document.execCommand("copy");
    298             document.body.removeChild(ta);
    299             return ok;
    300           } catch {
    301             return false;
    302           }
    303         }
    304       }
    305 
    306       async function api(path, body) {
    307         const res = await fetch(
    308           path,
    309           body
    310             ? {
    311                 method: "POST",
    312                 headers: { "Content-Type": "application/json", "X-Bzl-Launcher": API_TOKEN },
    313                 body: JSON.stringify(body)
    314               }
    315             : {}
    316         );
    317         return await res.json();
    318       }
    319 
    320       async function refresh() {
    321         const st = await api("/api/status");
    322         statusPill.textContent = st.healthy ? "Running" : st.serverRunning ? "Starting..." : "Stopped";
    323         const v = st.version ? ` v${st.version}` : "";
    324         statusText.textContent = st.localUrl + v + (st.healthy ? " (healthy)" : "");
    325         if (st.namedPublicUrl) setPublicUrl(st.namedPublicUrl);
    326       }
    327 
    328       async function refreshUpdate() {
    329         if (!updPill || !updText) return;
    330         const enabled = !!(updEnable && updEnable.checked);
    331         if (!enabled) {
    332           updPill.textContent = "Updates disabled";
    333           updText.textContent = "Enable updates to check GitHub releases and choose a version.";
    334           if (updSelect) updSelect.innerHTML = "";
    335           updSelected = null;
    336           if (updNotes) updNotes.disabled = true;
    337           return;
    338         }
    339         try {
    340           const st = await api("/api/update/releases");
    341           if (!st.ok) {
    342             updPill.textContent = "Update check failed";
    343             updText.textContent = st.error || "Failed to check updates.";
    344             return;
    345           }
    346           updReleases = Array.isArray(st.releases) ? st.releases : [];
    347           const current = st.currentVersion ? `v${st.currentVersion}` : "(unknown)";
    348           const latest = updReleases[0]?.version ? `v${updReleases[0].version}` : "(unknown)";
    349 
    350           updPill.textContent = "Ready";
    351           updText.textContent = `Current ${current}. Select a version to view notes or apply. Latest: ${latest}.`;
    352 
    353           if (updSelect) {
    354             const opts = updReleases
    355               .filter((r) => r && r.tag && r.asset && r.asset.url)
    356               .map((r) => ({
    357                 tag: r.tag,
    358                 label: `${r.version ? "v" + r.version : r.tag}${r.publishedAt ? " · " + r.publishedAt.slice(0, 10) : ""}${
    359                   r.prerelease ? " · prerelease" : ""
    360                 }`
    361               }));
    362             updSelect.innerHTML = opts.map((o) => `<option value="${o.tag}">${o.label}</option>`).join("");
    363             const want = updSelected?.tag || opts[0]?.tag || "";
    364             if (want) updSelect.value = want;
    365             updSelected = updReleases.find((r) => r.tag === updSelect.value) || null;
    366           }
    367           if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl);
    368         } catch (e) {
    369           updPill.textContent = "Update check failed";
    370           updText.textContent = e?.message || String(e);
    371         }
    372       }
    373 
    374       async function refreshCloudflared() {
    375         const st = await api("/api/cloudflared/status");
    376         if (!st.exists) {
    377           cfPill.textContent = "Missing cloudflared";
    378           cfStatusText.textContent = "Install cloudflared (Windows: winget install Cloudflare.cloudflared).";
    379           return;
    380         }
    381         cfPill.textContent = st.certExists ? "Logged in" : "Not logged in";
    382         const bits = [];
    383         if (st.version) bits.push(st.version);
    384         bits.push(st.certExists ? "cert ok" : "run Login");
    385         if (!st.configExists) {
    386           bits.push("no config.yml");
    387         } else if (st.configTunnel && st.envTunnel && st.configTunnel !== st.envTunnel) {
    388           bits.push("config tunnel mismatch");
    389         } else if (st.expectedCredentialsFile && st.configCredentialsFile && st.expectedCredentialsFile !== st.configCredentialsFile) {
    390           bits.push("creds mismatch");
    391         } else {
    392           bits.push("config ok");
    393         }
    394         if (st.setupRunning) bits.push("running...");
    395         cfStatusText.textContent = bits.join(" \u00b7 ");
    396 
    397         if (cfLoginUrlEl) {
    398           const u = String(st.lastLoginUrl || "").trim();
    399           cfLoginUrlEl.textContent = u || "(click Login)";
    400           cfLoginUrlEl.dataset.url = u;
    401         }
    402 
    403         if (!inpCfCfg.value) inpCfCfg.value = st.configPath || st.defaultConfigPath || "";
    404         if (!inpCfName.value) inpCfName.value = "bzl";
    405         if (st.lastCreate) {
    406           if (st.lastCreate.credentialsFile && !inpCfCreds.value) inpCfCreds.value = st.lastCreate.credentialsFile;
    407           if (st.lastCreate.tunnelId && !inpTunnel.value) inpTunnel.value = st.lastCreate.tunnelId;
    408         }
    409         if (st.expectedCredentialsFile && !inpCfCreds.value) inpCfCreds.value = st.expectedCredentialsFile;
    410       }
    411 
    412       function setDatalistOptions(id, values) {
    413         const el = document.getElementById(id);
    414         if (!el) return;
    415         const uniq = Array.from(new Set((values || []).filter(Boolean)));
    416         el.innerHTML = uniq.map((v) => `<option value="${String(v).replaceAll('"', "&quot;")}"></option>`).join("");
    417       }
    418 
    419       async function refreshCloudflaredFiles() {
    420         try {
    421           const f = await api("/api/cloudflared/files");
    422           if (!f.ok) return;
    423           setDatalistOptions("cfCfgList", [f.dir ? f.dir + "\\\\config.yml" : "", ...(f.configCandidates || [])].filter(Boolean));
    424           setDatalistOptions(
    425             "cfCredsList",
    426             (f.credentials || []).map((c) => c.path)
    427           );
    428           cfCredsById = new Map((f.credentials || []).filter((c) => c.tunnelId).map((c) => [c.tunnelId, c.path]));
    429           if (!inpCfCfg.value && f.configCandidates && f.configCandidates[0]) inpCfCfg.value = f.configCandidates[0];
    430         } catch {}
    431       }
    432 
    433       async function refreshCloudflaredTunnels() {
    434         try {
    435           const r = await api("/api/cloudflared/tunnels");
    436           if (!r.ok) return;
    437           cfTunnels = r.tunnels || [];
    438           setDatalistOptions(
    439             "cfTunnelNames",
    440             cfTunnels.map((t) => t.name)
    441           );
    442         } catch {}
    443       }
    444 
    445       document.getElementById("btnStart").onclick = async () => api("/api/start", { supervised: true });
    446       document.getElementById("btnStop").onclick = async () => api("/api/stop", {});
    447       document.getElementById("btnOpen").onclick = async () => api("/api/open", {});
    448       document.getElementById("btnTunnelQuick").onclick = async () => api("/api/tunnel/start", { mode: "quick" });
    449       document.getElementById("btnTunnelNamed").onclick = async () => api("/api/tunnel/start", { mode: "named" });
    450       document.getElementById("btnTunnelStop").onclick = async () => api("/api/tunnel/stop", {});
    451       if (updEnable) {
    452         updEnable.checked = localStorage.getItem(updEnabledKey) === "1";
    453         updEnable.onchange = () => {
    454           localStorage.setItem(updEnabledKey, updEnable.checked ? "1" : "0");
    455           refreshUpdate();
    456         };
    457       }
    458       document.getElementById("btnUpdCheck").onclick = async () => refreshUpdate();
    459       if (updSelect) {
    460         updSelect.onchange = () => {
    461           updSelected = updReleases.find((r) => r.tag === updSelect.value) || null;
    462           if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl);
    463         };
    464       }
    465       if (updNotes) {
    466         updNotes.onclick = () => {
    467           const url = updSelected?.htmlUrl || "";
    468           if (url) window.open(url, "_blank", "noopener,noreferrer");
    469         };
    470       }
    471       document.getElementById("btnUpdApply").onclick = async () => {
    472         if (!updEnable || !updEnable.checked) {
    473           append("update: enable updates first");
    474           return;
    475         }
    476         const tag = updSelected?.tag || (updSelect ? updSelect.value : "");
    477         if (!tag) {
    478           append("update: pick a version first");
    479           return;
    480         }
    481         append(`update: applying ${tag} (launcher will close)...`);
    482         const r = await api("/api/update/apply", { tag });
    483         append(r.ok ? "update: applying now (re-open launcher after it exits)" : `update: failed (${r.error || "?"})`);
    484       };
    485       document.getElementById("btnCopyPublic").onclick = async () => {
    486         const u = publicUrlEl?.dataset?.url || "";
    487         const ok = await copyText(u);
    488         append(ok ? `copied: ${u}` : "copy failed");
    489       };
    490       {
    491         const btn = document.getElementById("btnCopyLogin");
    492         if (btn) {
    493           btn.onclick = async () => {
    494             const u = cfLoginUrlEl?.dataset?.url || "";
    495             const ok = await copyText(u);
    496             append(ok ? `copied: ${u}` : "copy failed");
    497           };
    498         }
    499       }
    500       document.getElementById("btnCfRefresh").onclick = async () => {
    501         await refreshCloudflared();
    502         await refreshCloudflaredFiles();
    503         await refreshCloudflaredTunnels();
    504       };
    505       document.getElementById("btnCfCancel").onclick = async () => api("/api/cloudflared/cancel", {});
    506       document.getElementById("btnCfLogin").onclick = async () => {
    507         append("cloudflared: login starting (browser will open)");
    508         await api("/api/cloudflared/login", {});
    509         refreshCloudflared();
    510       };
    511       document.getElementById("btnCfCreate").onclick = async () => {
    512         const name = inpCfName.value || "bzl";
    513         const r = await api("/api/cloudflared/create", { name });
    514         if (r.tunnelId) inpTunnel.value = r.tunnelId;
    515         if (r.credentialsFile) inpCfCreds.value = r.credentialsFile;
    516         if (inpCfCfg.value) inpCfg.value = inpCfCfg.value;
    517         append(
    518           r.ok
    519             ? `${r.reused ? "cloudflared: using existing tunnel" : "cloudflared: created tunnel"} (${r.tunnelId || name})`
    520             : `cloudflared: create failed (${r.error || r.code || "?"})`
    521         );
    522         refreshCloudflared();
    523       };
    524       document.getElementById("btnCfRoute").onclick = async () => {
    525         const tunnel = inpTunnel.value || inpCfName.value;
    526         const hostname = inpCfHost.value;
    527         const r = await api("/api/cloudflared/route-dns", { tunnel, hostname, force: true });
    528         append(r.ok ? `cloudflared: routed ${hostname}` : `cloudflared: route failed (${r.error || r.code || "?"})`);
    529       };
    530       document.getElementById("btnCfWriteCfg").onclick = async () => {
    531         const tunnelId = inpTunnel.value;
    532         const hostname = inpCfHost.value;
    533         const configPath = inpCfCfg.value;
    534         const credentialsFile = inpCfCreds.value;
    535         const port = inpPort.value;
    536         const r = await api("/api/cloudflared/write-config", { tunnelId, hostname, configPath, credentialsFile, port });
    537         append(r.ok ? `cloudflared: wrote config (${r.configPath})` : `cloudflared: write config failed (${r.error || "?"})`);
    538         if (r.ok) inpCfg.value = r.configPath;
    539         refreshCloudflared();
    540       };
    541 
    542       function uuidFromText(s) {
    543         const m = String(s || "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
    544         return m ? m[0] : "";
    545       }
    546 
    547       inpCfCreds.onchange = () => {
    548         const id = uuidFromText(inpCfCreds.value);
    549         if (id) {
    550           inpTunnel.value = id;
    551           append(`cloudflared: selected creds -> tunnel ${id}`);
    552         }
    553         if (cfCredsHint) {
    554           const t = (cfTunnels || []).find((x) => x.id === id);
    555           cfCredsHint.textContent = id ? `Detected: ${t?.name ? t.name + " " : ""}(${id})` : "";
    556         }
    557         refreshCloudflared();
    558       };
    559       inpCfName.onchange = () => {
    560         const name = String(inpCfName.value || "").trim();
    561         const t = (cfTunnels || []).find((x) => x.name === name);
    562         if (t?.id) {
    563           inpTunnel.value = t.id;
    564           if (!inpCfCreds.value) {
    565             const p = cfCredsById.get(t.id);
    566             if (p) inpCfCreds.value = p;
    567           }
    568           append(`cloudflared: selected tunnel ${name} -> ${t.id}`);
    569         }
    570         if (cfCredsHint) {
    571           cfCredsHint.textContent = t?.id ? `Selected tunnel: ${name} (${t.id})` : "";
    572         }
    573       };
    574       document.getElementById("btnSave").onclick = async () => {
    575         await api("/api/config", {
    576           PORT: inpPort.value,
    577           HOST: inpHost.value,
    578           REGISTRATION_CODE: inpReg.value,
    579           CLOUDFLARED_TUNNEL: inpTunnel.value,
    580           CLOUDFLARED_CONFIG: inpCfg.value
    581         });
    582         append("settings saved");
    583       };
    584       document.getElementById("btnReload").onclick = () => location.reload();
    585       document.getElementById("btnClear").onclick = () => {
    586         logEl.textContent = "";
    587       };
    588 
    589       (async () => {
    590         const snap = await api("/api/log");
    591         logEl.textContent = (snap.lines || []).join("\n") + "\n";
    592         refresh();
    593         refreshUpdate();
    594         refreshCloudflared();
    595         refreshCloudflaredFiles();
    596         refreshCloudflaredTunnels();
    597         setInterval(refresh, 1000);
    598         setInterval(refreshUpdate, 60000);
    599         setInterval(refreshCloudflared, 3000);
    600         setInterval(refreshCloudflaredFiles, 5000);
    601         setInterval(refreshCloudflaredTunnels, 30000);
    602       })();
    603 
    604       const es = new EventSource("/api/log/stream");
    605       es.onmessage = (ev) => {
    606         try {
    607           const msg = JSON.parse(ev.data || "{}");
    608           if (msg.line) {
    609             append(msg.line);
    610             const m = String(msg.line).match(/https?:\/\/[^\s]+/i);
    611             if (m && /trycloudflare\.com/i.test(m[0])) setPublicUrl(m[0]);
    612           }
    613         } catch {}
    614       };
    615     </script>
    616   </body>
    617 </html>