Files
llmgw/internal/web/templates/install.html
T
Ilker ManapandClaude Opus 5.5 fea5774fe5 The install guide shows every screen of a real install
/install takes a reader from three bare Ubuntu machines to a first answer:
what each machine needs and the commands that check it, the release fetched
and its checksum checked, the eight setup screens, the system's state and
the console, and a first request with a key from the account page. Every
screen is from an install of v0.14.9 on three fresh VMs, recorded with tmux
and kept as terminal frames: the page shows them as text, and plays a
session back when asked, fetching it compressed from /install/cast/. The
administrator's password and the API key were masked before the recording
was kept, and a test fails if either shape reaches the page.

/about gains what llmgw now does beyond routing, and its install steps are
today's: the setup screens and node add, not onboard-node.sh and a model
file, with a link into the guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:35:08 +02:00

368 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "install"}}{{if not .Site}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="{{t "install.meta.description"}}">
{{end}}<title>{{t "install.title"}} · llmgw</title>
{{template "sitestyle" .}}
<style>
/* the recorded terminal */
.tty{margin-top:18px;background:#0f1416;border:1px solid #22302f;border-radius:10px;overflow:hidden;min-width:0}
.tty-bar{display:flex;align-items:center;gap:12px;padding:9px 12px;background:#161e20;border-bottom:1px solid #22302f}
.tty-dots{display:flex;gap:6px}
.tty-dots i{width:11px;height:11px;border-radius:50%;background:#33403f}
.tty-title{flex:1;font:500 .75rem/1 var(--mono);color:#7f9a97;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tty-play{font:600 .78rem/1 var(--body);padding:7px 12px;border-radius:6px;border:1px solid #2d4442;background:#1c2b2a;color:#dceae8;cursor:pointer}
.tty-play:hover{border-color:#7f9a97}
.tty-screen{margin:0;padding:12px 14px;overflow-x:auto;color:#d6d8d6;font:400 clamp(7px,1.18vw,13px)/1.22 var(--mono);white-space:pre;tab-size:8}
.tty-progress{height:3px;background:#1c2b2a}
.tty-progress i{display:block;height:3px;width:0;background:var(--fiber);transition:width .2s linear}
.tty figcaption,.shot figcaption{padding:10px 14px 12px;font-size:.85rem;color:#9fb3b1;border-top:1px solid #22302f}
.shot{margin-top:18px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
.shot img{display:block;width:100%;height:auto}
.shot figcaption{color:var(--muted);border-top:1px solid var(--line)}
.shots{display:grid;grid-template-columns:1fr;gap:18px}
.full{margin-top:28px}
.req td:first-child{white-space:nowrap}
.note-box{margin-top:18px;padding:14px 16px;border-left:3px solid var(--om1);background:var(--om1-soft);border-radius:0 8px 8px 0;font-size:.92rem}
.toc{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px;padding:0;list-style:none}
.toc a{display:inline-block;padding:8px 12px;border:1px solid var(--line);border-radius:999px;text-decoration:none;color:var(--ink);font-size:.88rem;background:var(--surface)}
.toc a b{color:var(--fiber);margin-right:6px}
.facts-row{display:flex;flex-wrap:wrap;gap:10px 28px;margin-top:26px;color:var(--muted);font-size:.9rem}
.facts-row b{display:block;font:800 1.5rem/1.1 var(--display);font-variation-settings:"wdth" 125;color:var(--ink)}
/* the setup screens, one at a time */
.screens input{position:absolute;opacity:0;pointer-events:none}
.screen-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.screen-tabs label{cursor:pointer;padding:8px 11px;border:1px solid var(--line);border-radius:8px;font:500 .84rem/1 var(--body);color:var(--muted);background:var(--surface)}
.screen-tabs label b{color:var(--fiber);margin-right:5px}
.screen{display:none}
.screen .what{margin-top:16px;max-width:52em;color:var(--muted)}
.screen .what strong{color:var(--ink)}
#sc1:checked~.screen-tabs label[for="sc1"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc1:checked~.sc1{display:block}
#sc2:checked~.screen-tabs label[for="sc2"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc2:checked~.sc2{display:block}
#sc3:checked~.screen-tabs label[for="sc3"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc3:checked~.sc3{display:block}
#sc4:checked~.screen-tabs label[for="sc4"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc4:checked~.sc4{display:block}
#sc5:checked~.screen-tabs label[for="sc5"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc5:checked~.sc5{display:block}
#sc6:checked~.screen-tabs label[for="sc6"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc6:checked~.sc6{display:block}
#sc7:checked~.screen-tabs label[for="sc7"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc7:checked~.sc7{display:block}
#sc8:checked~.screen-tabs label[for="sc8"]{color:var(--ink);border-color:var(--fiber);background:var(--fiber-soft)}
#sc8:checked~.sc8{display:block}
.trouble td{vertical-align:top}
.trouble td:first-child{white-space:normal;width:38%}
@media (max-width:860px){.shots{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.tty-progress i{transition:none}}
</style>
{{if not .Site}}</head><body>{{end}}
<div class="wrap">
<header class="bar">
<a class="mark" href="{{.AboutURL}}" aria-label="{{t "about.home_label"}}">
<svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M7 9h12M7 13h12M7 17h12" stroke="var(--fiber)" stroke-width="2" stroke-linecap="round"/>
</svg>
llmgw
</a>
<nav aria-label="{{t "install.nav.label"}}">
<a href="#before">{{t "install.nav.before"}}</a>
<a href="#binary">{{t "install.nav.binary"}}</a>
<a href="#setup">{{t "install.nav.setup"}}</a>
<a href="#check">{{t "install.nav.check"}}</a>
<a href="#use">{{t "install.nav.use"}}</a>
<a href="#trouble">{{t "install.nav.trouble"}}</a>
<a href="{{.AboutURL}}">{{t "install.nav.about"}}</a>
</nav>
</header>
<div class="hero" id="top" style="grid-template-columns:1fr">
<div>
<p class="eyebrow">{{t "install.hero.eyebrow" .Recorded}}</p>
<h1 style="margin-top:14px">{{th "install.hero.title"}}</h1>
<p class="lede" style="max-width:46em">{{t "install.hero.lede"}}</p>
<div class="facts-row">
<span><b>3</b>{{t "install.hero.machines"}}</span>
<span><b>4</b>{{t "install.hero.steps"}}</span>
<span><b>{{t "install.hero.time_value"}}</b>{{t "install.hero.time"}}</span>
<span><b>0</b>{{t "install.hero.hidden"}}</span>
</div>
<ol class="toc">
<li><a href="#before"><b>0</b>{{t "install.nav.before"}}</a></li>
<li><a href="#binary"><b>1</b>{{t "install.nav.binary"}}</a></li>
<li><a href="#setup"><b>2</b>{{t "install.nav.setup"}}</a></li>
<li><a href="#check"><b>3</b>{{t "install.nav.check"}}</a></li>
<li><a href="#use"><b>4</b>{{t "install.nav.use"}}</a></li>
<li><a href="#next"><b>→</b>{{t "install.nav.next"}}</a></li>
</ol>
</div>
</div>
<section id="before">
<div class="sec-head">
<div>
<p class="eyebrow">{{t "install.step" "0"}}</p>
<h2 style="margin-top:8px">{{t "install.before.title"}}</h2>
<p class="intro">{{t "install.before.intro"}}</p>
</div>
<div>
<div class="dl-wrap">
<table class="dl req">
<tbody>
<tr><td>{{t "install.req.machines"}}</td><td>{{t "install.req.machines.text"}}</td></tr>
<tr><td>{{t "install.req.system"}}</td><td>{{t "install.req.system.text"}}</td></tr>
<tr><td>{{t "install.req.size"}}</td><td>{{t "install.req.size.text"}}</td></tr>
<tr><td>{{t "install.req.clock"}}</td><td>{{t "install.req.clock.text"}}</td></tr>
<tr><td>{{t "install.req.network"}}</td><td>{{th "install.req.network.text"}}</td></tr>
<tr><td>{{t "install.req.access"}}</td><td>{{th "install.req.access.text"}}</td></tr>
<tr><td>{{t "install.req.names"}}</td><td>{{t "install.req.names.text"}}</td></tr>
<tr><td>{{t "install.req.gpus"}}</td><td>{{t "install.req.gpus.text"}}</td></tr>
</tbody>
</table>
</div>
<p class="note-box">{{th "install.req.memory_note"}}</p>
<p style="margin-top:18px;color:var(--muted)">{{t "install.before.commands"}}</p>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>grep PRETTY_NAME /etc/os-release; uname -m; nproc; free -h | grep Mem; df -h /
timedatectl | grep synchronized; curl -sI https://gitea.mynodes.xyz | head -1
ping -c1 &lt;second machine&gt;; ping -c1 &lt;third machine&gt;
sudo -v &amp;&amp; echo "sudo works"
sudo hostnamectl set-hostname llm-1 <span class="c"># a name of its own, on each machine</span></code></pre></div>
</div>
</div>
{{template "tty" (.Cast "0-prereq")}}
</section>
<section id="binary">
<div class="sec-head">
<div>
<p class="eyebrow">{{t "install.step" "1"}}</p>
<h2 style="margin-top:8px">{{t "install.binary.title"}}</h2>
<p class="intro">{{t "install.binary.intro"}}</p>
</div>
<div>
<p style="color:var(--muted)">{{th "install.binary.text"}}</p>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>VERSION=$(curl -fsSL https://gitea.mynodes.xyz/api/v1/repos/ilker/llmgw/releases/latest \
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
U=https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION
curl -fsSLO $U/llmgw-$VERSION-linux-amd64 <span class="c"># -linux-arm64 on ARM</span>
curl -fsSL $U/checksums.txt | sha256sum -c --ignore-missing
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw
llmgw --version</code></pre></div>
</div>
</div>
{{template "tty" (.Cast "1-binary")}}
</section>
<section id="setup">
<div class="sec-head">
<div>
<p class="eyebrow">{{t "install.step" "2"}}</p>
<h2 style="margin-top:8px">{{t "install.setup.title"}}</h2>
<p class="intro">{{t "install.setup.intro"}}</p>
</div>
<div>
<p style="color:var(--muted)">{{t "install.setup.text"}}</p>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>sudo llmgw</code></pre></div>
<p style="margin-top:14px;color:var(--muted);font-size:.9rem">{{t "install.setup.keys"}}</p>
</div>
</div>
<div class="screens full">
<input type="radio" name="screen" id="sc1" checked><input type="radio" name="screen" id="sc2"><input type="radio" name="screen" id="sc3"><input type="radio" name="screen" id="sc4">
<input type="radio" name="screen" id="sc5"><input type="radio" name="screen" id="sc6"><input type="radio" name="screen" id="sc7"><input type="radio" name="screen" id="sc8">
<div class="screen-tabs" role="presentation">
<label for="sc1"><b>2.1</b>{{t "install.sc1.tab"}}</label>
<label for="sc2"><b>2.2</b>{{t "install.sc2.tab"}}</label>
<label for="sc3"><b>2.3</b>{{t "install.sc3.tab"}}</label>
<label for="sc4"><b>2.4</b>{{t "install.sc4.tab"}}</label>
<label for="sc5"><b>2.5</b>{{t "install.sc5.tab"}}</label>
<label for="sc6"><b>2.6</b>{{t "install.sc6.tab"}}</label>
<label for="sc7"><b>2.7</b>{{t "install.sc7.tab"}}</label>
<label for="sc8"><b>2.8</b>{{t "install.sc8.tab"}}</label>
</div>
<div class="screen sc1">{{template "still" (.Shot "setup-start" "2.1")}}<p class="what">{{th "install.sc1.text"}}</p></div>
<div class="screen sc2">{{template "still" (.Shot "setup-choices" "2.2")}}<p class="what">{{th "install.sc2.text"}}</p></div>
<div class="screen sc3">{{template "still" (.Shot "setup-admin" "2.3")}}<p class="what">{{th "install.sc3.text"}}</p></div>
<div class="screen sc4">{{template "still" (.Shot "setup-machines" "2.4")}}<p class="what">{{th "install.sc4.text"}}</p></div>
<div class="screen sc5">{{template "still" (.Shot "setup-checks" "2.5")}}<p class="what">{{th "install.sc5.text"}}</p></div>
<div class="screen sc6">{{template "still" (.Shot "setup-plan" "2.6")}}<p class="what">{{th "install.sc6.text"}}</p></div>
<div class="screen sc7">{{template "still" (.Shot "setup-install" "2.7")}}<p class="what">{{th "install.sc7.text"}}</p></div>
<div class="screen sc8">{{template "still" (.Shot "setup-done" "2.8")}}<p class="what">{{th "install.sc8.text"}}</p></div>
</div>
<div class="full">
<h3>{{t "install.setup.whole"}}</h3>
{{template "tty" (.Cast "2-setup")}}
</div>
<div class="full sec-head">
<div><h3>{{t "install.setup.script.title"}}</h3><p class="intro">{{t "install.setup.script.intro"}}</p></div>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>sudo --preserve-env=SSH_AUTH_SOCK llmgw server init \
--admin you@example.com \
--patroni &lt;user&gt;@&lt;second machine&gt; \
--patroni &lt;user&gt;@&lt;third machine&gt; \
--standbys
sudo llmgw server init --admin you@example.com <span class="c"># one machine</span></code></pre></div>
</div>
</section>
<section id="check">
<div class="sec-head">
<div>
<p class="eyebrow">{{t "install.step" "3"}}</p>
<h2 style="margin-top:8px">{{t "install.check.title"}}</h2>
<p class="intro">{{t "install.check.intro"}}</p>
</div>
<div>
<p style="color:var(--muted)">{{th "install.check.text"}}</p>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>sudo llmgw status
sudo llmgw <span class="c"># the console</span></code></pre></div>
</div>
</div>
{{template "tty" (.Cast "3-check")}}
<div class="shots">
<figure class="shot"><img src="{{.Img "login.png"}}" alt="{{t "install.shot.login"}}" loading="lazy" width="1280" height="650"><figcaption>{{t "install.shot.login"}}</figcaption></figure>
<figure class="shot"><img src="{{.Img "admin.png"}}" alt="{{t "install.shot.admin"}}" loading="lazy" width="1280" height="650"><figcaption>{{t "install.shot.admin"}}</figcaption></figure>
</div>
</section>
<section id="use">
<div class="sec-head">
<div>
<p class="eyebrow">{{t "install.step" "4"}}</p>
<h2 style="margin-top:8px">{{t "install.use.title"}}</h2>
<p class="intro">{{t "install.use.intro"}}</p>
</div>
<div>
<p style="color:var(--muted)">{{th "install.use.text"}}</p>
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>export LLMGW_API_KEY=sk-llmgw-… <span class="c"># from the account page</span>
curl http://&lt;first machine&gt;:8080/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "qwen-0.5b", "messages": [{"role": "user", "content": "hello"}]}'</code></pre></div>
</div>
</div>
<div class="shots">
<figure class="shot"><img src="{{.Img "newkey.png"}}" alt="{{t "install.shot.newkey"}}" loading="lazy" width="1280" height="650"><figcaption>{{t "install.shot.newkey"}}</figcaption></figure>
<figure class="shot"><img src="{{.Img "chat.png"}}" alt="{{t "install.shot.chat"}}" loading="lazy" width="1280" height="650"><figcaption>{{t "install.shot.chat"}}</figcaption></figure>
</div>
{{template "tty" (.Cast "4-request")}}
</section>
<section id="next">
<div class="sec-head">
<div>
<h2>{{t "install.next.title"}}</h2>
<p class="intro">{{t "install.next.intro"}}</p>
</div>
<div class="facts">
<div class="fact"><h3>{{t "install.next.nodes.title"}}</h3><p>{{th "install.next.nodes.text"}}</p></div>
<div class="fact"><h3>{{t "install.next.models.title"}}</h3><p>{{th "install.next.models.text"}}</p></div>
<div class="fact"><h3>{{t "install.next.people.title"}}</h3><p>{{th "install.next.people.text"}}</p></div>
<div class="fact"><h3>{{t "install.next.https.title"}}</h3><p>{{th "install.next.https.text" .AboutURL}}</p></div>
</div>
</div>
</section>
<section id="trouble">
<div class="sec-head">
<div>
<h2>{{t "install.trouble.title"}}</h2>
<p class="intro">{{t "install.trouble.intro"}}</p>
</div>
<div class="dl-wrap">
<table class="dl trouble">
<thead><tr><th>{{t "install.trouble.see"}}</th><th>{{t "install.trouble.do"}}</th></tr></thead>
<tbody>
<tr><td>{{th "install.trouble.starter.see"}}</td><td>{{th "install.trouble.starter.do"}}</td></tr>
<tr><td>{{th "install.trouble.judge.see"}}</td><td>{{th "install.trouble.judge.do"}}</td></tr>
<tr><td>{{th "install.trouble.ssh.see"}}</td><td>{{th "install.trouble.ssh.do"}}</td></tr>
<tr><td>{{th "install.trouble.check.see"}}</td><td>{{th "install.trouble.check.do"}}</td></tr>
<tr><td>{{th "install.trouble.node.see"}}</td><td>{{th "install.trouble.node.do"}}</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<footer>
<span>llmgw {{.Version}} · {{t "install.footer.recorded" .Recorded}}</span>
<span><a href="{{.AboutURL}}">{{t "install.nav.about"}}</a> ·
<a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/install.md">{{t "install.footer.full"}}</a></span>{{if not .Site}}{{template "langs"}}{{end}}
</footer>
</div>
<script>
var say = { copy: {{t "about.copy"}}, copied: {{t "about.copied"}}, selected: {{t "about.selected"}},
play: {{t "install.play"}}, pause: {{t "install.pause"}}, again: {{t "install.again"}} };
document.querySelectorAll('.copy').forEach(function (b) {
b.addEventListener('click', function () {
var text = b.parentElement.querySelector('pre').innerText;
var done = function (label) { b.textContent = label; setTimeout(function () { b.textContent = say.copy; }, 1600); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { done(say.copied); }, select);
} else { select(); }
function select() {
var r = document.createRange(); r.selectNodeContents(b.parentElement.querySelector('pre'));
var s = window.getSelection(); s.removeAllRanges(); s.addRange(r); done(say.selected);
}
});
});
// The player: a recorded session, frame by frame. The page shows its last
// screen without it; this only adds the button that plays it back.
document.querySelectorAll('.tty[data-src]').forEach(function (el) {
var screen = el.querySelector('.tty-screen');
var bar = el.querySelector('.tty-progress i'), btn = el.querySelector('.tty-play');
var cast = null, total = 0, lines = [], i = 0, elapsed = 0, timer = null;
btn.hidden = false;
function show(k) {
var f = cast.frames[k];
if (f.l) { lines = f.l.slice(); } else { for (var r in f.c) { lines[+r] = f.c[r]; } }
screen.innerHTML = lines.join('\n');
}
function tick() {
if (i >= cast.frames.length) { timer = null; btn.textContent = say.again; return; }
show(i); elapsed += cast.frames[i].d; bar.style.width = (100 * elapsed / total) + '%';
timer = setTimeout(tick, cast.frames[i].d * 1000); i++;
}
function start() {
if (i >= cast.frames.length) { i = 0; elapsed = 0; lines = []; }
btn.textContent = say.pause; tick();
}
btn.addEventListener('click', function () {
if (timer) { clearTimeout(timer); timer = null; btn.textContent = say.play; return; }
if (cast) { start(); return; }
// Fetched only now: most readers never play most recordings.
btn.disabled = true;
fetch(el.dataset.src).then(function (r) { return r.json(); }).then(function (c) {
cast = c; total = c.frames.reduce(function (s, f) { return s + f.d; }, 0);
// The whole terminal, so the page does not jump as the session fills it.
screen.style.minHeight = 'calc(' + (c.rows * 1.22) + 'em + 24px)';
btn.disabled = false; start();
}, function () { btn.disabled = false; });
});
});
</script>
{{if not .Site}}</body></html>{{end}}{{end}}
{{define "tty"}}<figure class="tty" data-src="{{.Src}}">
<div class="tty-bar"><span class="tty-dots" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="tty-title" translate="no">ilker@llm-1 · {{.Cols}}×{{.Rows}}</span>
<button class="tty-play" type="button" hidden>{{t "install.play"}}</button></div>
<pre class="tty-screen" translate="no" aria-label="{{t "install.tty_label"}}">{{.Poster}}</pre>
<div class="tty-progress"><i></i></div>
<figcaption>{{t (print "install.cast." .Name)}}</figcaption>
</figure>{{end}}
{{define "still"}}<figure class="tty">
<div class="tty-bar"><span class="tty-dots" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="tty-title" translate="no">sudo llmgw · {{.Label}}</span></div>
<pre class="tty-screen" translate="no" aria-label="{{t "install.tty_label"}}">{{.Screen}}</pre>
</figure>{{end}}