tiny-llm / src /tinyllm /web /status.html
CoolDip00's picture
tiny-llm 9e83e17: D-031: takeover sub-plan and progress notes
b446ce4 verified
Raw History Blame Contribute Delete
22.5 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tiny-llm status</title>
<style>
:root {
color-scheme: light;
--page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
--grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11,11,11,0.10);
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --track: #cde2fb;
--good: #0ca30c; --warn: #fab219; --bad: #d03b3b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --track: #184f95;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --track: #184f95;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; }
h1 { font-size: 22px; margin: 0; font-weight: 600; }
h2 { font-size: 16px; margin: 0 0 2px; font-weight: 600; }
a { color: var(--s1); }
.meta, .sub { color: var(--ink-2); font-size: 13px; }
.sub { margin: 0 0 10px; }
.banner { display: none; margin: 12px 0 0; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--ring); background: var(--surface); }
.banner.show { display: block; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin: 20px 0 8px; }
.card, .panel { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px; padding: 14px 16px; }
.card .value { font-size: 28px; font-weight: 600; margin: 4px 0 0; }
.card .line { color: var(--ink-2); font-size: 13px; margin: 2px 0; }
.state { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; }
.state i { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
font-style: normal; font-size: 11px; color: #fff; flex: none; }
.state.none { font-weight: 400; align-items: flex-start; } .state.none i { margin-top: 2px; }
.state.good i { background: var(--good); } .state.bad i { background: var(--bad); }
.state.warn i { background: var(--warn); color: #0b0b0b; } .state.none i { background: var(--muted); }
.meter { height: 8px; border-radius: 4px; background: var(--track); margin: 8px 0 4px; overflow: hidden; }
.meter > div { height: 100%; border-radius: 4px; background: var(--s1); }
.window { color: var(--ink-2); font-size: 13px; margin: 0 0 16px; }
.panel { margin: 12px 0; position: relative; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--ink-2); margin: 0 0 6px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.key { display: inline-block; width: 14px; height: 0; border-top: 2px solid; }
.key.box { width: 10px; height: 10px; border: 0; border-radius: 2px; }
.key.ref { border-top: 1px solid var(--muted); }
.chart { width: 100%; height: 190px; display: block; outline: none; }
.chart:focus-visible { box-shadow: 0 0 0 2px var(--s1); border-radius: 4px; }
.chart text { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.tip { position: absolute; pointer-events: none; display: none; min-width: 140px; z-index: 2;
background: var(--surface); border: 1px solid var(--ring); border-radius: 8px;
padding: 8px 10px; font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.tip .t { color: var(--ink-2); margin-bottom: 4px; }
.tip .r { display: flex; align-items: center; gap: 8px; }
.tip .r b { font-weight: 600; min-width: 52px; }
.tip .r span { color: var(--ink-2); }
details { margin: 16px 0; }
summary { cursor: pointer; color: var(--ink-2); }
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; font-variant-numeric: tabular-nums; }
th, td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--grid); }
th:first-child, td:first-child { text-align: left; }
.tablewrap { overflow-x: auto; }
footer { color: var(--ink-2); font-size: 13px; margin-top: 28px; }
.stale { opacity: 0.55; }
</style>
</head>
<body>
<main>
<header>
<h1>Status</h1>
<span class="meta" id="meta">Loading…</span>
</header>
<p class="meta"><a href="./">← Demo</a> Β· numbers are measured on this server, from arrival to token sent</p>
<div class="banner" id="banner" role="status"></div>
<section class="cards" id="cards" aria-label="Service level objectives"></section>
<p class="window" id="window"></p>
<section class="panel" id="replicas-panel" hidden>
<h2>Replicas</h2>
<p class="sub">State and queue from the last poll. The router polls only while visitors are active, so with no traffic this says "Not polled".</p>
<div class="tablewrap"><table id="replicas"></table></div>
</section>
<section class="panel">
<h2>Requests per minute</h2>
<p class="sub">Last 60 minutes, by outcome</p>
<div class="legend">
<span><i class="key box" style="background:var(--s1)"></i>Served</span>
<span><i class="key box" style="background:var(--s2)"></i>Busy (429)</span>
<span><i class="key box" style="background:var(--s3)"></i>Server error (5xx)</span>
</div>
<svg class="chart" id="c-req" tabindex="0" role="img" aria-label="Requests per minute, last hour"></svg>
<div class="tip"></div>
</section>
<section class="panel">
<h2>Latency</h2>
<p class="sub">Per minute, successful requests; time to first token includes the wait for a slot</p>
<div class="legend">
<span><i class="key" style="border-color:var(--s1)"></i>First token p50</span>
<span><i class="key" style="border-color:var(--s2)"></i>First token p95</span>
<span><i class="key" style="border-color:var(--s3)"></i>Whole request p95</span>
<span><i class="key ref"></i><span id="ref-label">SLO</span></span>
</div>
<svg class="chart" id="c-lat" tabindex="0" role="img" aria-label="Latency per minute, last hour"></svg>
<div class="tip"></div>
</section>
<section class="panel">
<h2>Load</h2>
<p class="sub">Engine snapshots every 5 s</p>
<div class="legend">
<span><i class="key" style="border-color:var(--s1)"></i>Busy slots</span>
<span><i class="key" style="border-color:var(--s2)"></i>Waiting in queue</span>
<span><i class="key ref"></i><span id="slots-label">Slots</span></span>
</div>
<svg class="chart" id="c-load" tabindex="0" role="img" aria-label="Busy slots and queue length, last hour"></svg>
<div class="tip"></div>
</section>
<details>
<summary>Show the last hour as a table</summary>
<div class="tablewrap"><table id="table"></table></div>
</details>
<footer>
SLOs and the error budget are defined in
<a href="https://github.com/cooldip00/tiny-llm/blob/main/docs/SERVING.md#slos-413-step-3-written-before-the-load-test">docs/SERVING.md</a>.
History is kept in memory, so it starts again when the server restarts.
The server runs on CPU here; the speed numbers in the project README come from a laptop GPU.
</footer>
</main>
<script>
"use strict";
const NS = "http://www.w3.org/2000/svg";
const SLO_NAMES = { ttft: "Time to first token", availability: "Availability", shed: "Requests not shed" };
let last = null;
function el(tag, attrs, parent, text) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
if (text !== undefined) e.textContent = text;
if (parent) parent.appendChild(e);
return e;
}
function h(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function niceStep(v) {
if (!(v > 0)) return 1;
const p = Math.pow(10, Math.floor(Math.log10(v)));
for (const m of [1, 2, 5]) if (m * p >= v) return m * p;
return 10 * p;
}
const pct = (x, d = 1) => x === null || x === undefined ? "–" : (100 * x).toFixed(d) + "%";
function secs(x) {
if (x === null || x === undefined) return "–";
return x < 1 ? Math.round(x * 1000) + " ms" : x.toFixed(2) + " s";
}
function dur(s) {
s = Math.round(s);
const d = Math.floor(s / 86400), hh = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
if (d) return `${d} d ${hh} h`;
if (hh) return `${hh} h ${m} min`;
return m ? `${m} min` : `${s} s`;
}
const minLabel = (ago) => ago === 0 ? "this minute" : `${ago} min ago`;
/* One chart: x in minutes from -60 to 0; series of {name, color, values: [{x, y}|null...]}.
kind "stack": columns, one per minute; kind "line": 2px lines with an end dot, and a dot on lone points. */
function drawChart(svg, spec) {
const W = svg.clientWidth || 600, H = svg.clientHeight || 190;
const m = { l: 44, r: 14, t: 10, b: 24 };
const pw = W - m.l - m.r, ph = H - m.t - m.b;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.replaceChildren();
const X = (x) => m.l + (x + 60) / 60 * pw;
let ymax = spec.ymin || 0;
for (const p of spec.points) ymax = Math.max(ymax, spec.total(p));
if (spec.ref) ymax = Math.max(ymax, spec.ref.value * 1.15);
let step = niceStep(ymax / 4); // ticks on 1 / 2 / 5 Γ— 10^k, 3–5 gridlines
if (spec.integer) step = Math.max(1, Math.round(step));
const nTicks = Math.max(1, Math.ceil(ymax / step - 1e-9));
ymax = nTicks * step;
const Y = (y) => m.t + ph - y / ymax * ph;
for (let i = 0; i <= nTicks; i++) {
const v = +(step * i).toPrecision(12), y = Y(v);
el("line", { x1: m.l, x2: W - m.r, y1: y, y2: y, stroke: i ? "var(--grid)" : "var(--axis)", "stroke-width": 1 }, svg);
el("text", { x: m.l - 6, y: y + 4, "text-anchor": "end" }, svg, spec.fmt(v));
}
for (const ago of [60, 45, 30, 15, 0])
el("text", { x: X(-ago), y: H - 6, "text-anchor": ago === 60 ? "start" : ago === 0 ? "end" : "middle" },
svg, ago ? `βˆ’${ago} min` : "now");
if (spec.ref) {
const y = Y(spec.ref.value);
el("line", { x1: m.l, x2: W - m.r, y1: y, y2: y, stroke: "var(--muted)", "stroke-width": 1 }, svg);
el("text", { x: W - m.r, y: y - 4, "text-anchor": "end" }, svg, spec.ref.label);
}
if (spec.kind === "stack") {
const slot = pw / 60, bw = Math.max(2, Math.min(24, slot - 2));
for (const p of spec.points) {
if (!p) continue;
let base = 0;
const parts = spec.series.map((s) => ({ s, v: s.get(p) })).filter((q) => q.v > 0);
parts.forEach((q, i) => {
const x = X(p.x) - slot / 2 + (slot - bw) / 2, y0 = Y(base), y1 = Y(base + q.v);
const top = i === parts.length - 1, gap = i ? 2 : 0, hgt = Math.max(0, y0 - y1 - gap);
const r = top ? Math.min(2, bw / 2, hgt) : 0, yb = y0 - gap;
el("path", { d: `M${x},${yb} V${yb - hgt + r} Q${x},${yb - hgt} ${x + r},${yb - hgt} H${x + bw - r} ` +
`Q${x + bw},${yb - hgt} ${x + bw},${yb - hgt + r} V${yb} Z`, fill: q.s.color }, svg);
base += q.v;
});
}
} else {
for (const s of spec.series) {
// A point with no neighbour on either side is a lone "M" (zero length, never drawn),
// so each such point gets its own small dot; sparse traffic makes most minutes like that.
let d = "", lastPt = null, prev = null, run = [];
const lone = [];
const endRun = () => { if (run.length === 1) lone.push(run[0]); run = []; };
for (const p of spec.points) {
const v = p ? s.get(p) : null;
if (v === null || v === undefined) { endRun(); prev = null; continue; }
const gapBreak = prev && p.x - prev.x > (spec.maxGap || 1.01);
if (!prev || gapBreak) endRun();
const pt = [X(p.x), Y(v)];
d += `${prev && !gapBreak ? "L" : "M"}${pt[0].toFixed(1)},${pt[1].toFixed(1)} `;
run.push(pt); prev = p; lastPt = pt;
}
endRun();
if (d) el("path", { d, fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }, svg);
for (const q of lone) if (q !== lastPt) el("circle", { cx: q[0], cy: q[1], r: 3, fill: s.color }, svg);
if (lastPt) el("circle", { cx: lastPt[0], cy: lastPt[1], r: 4, fill: s.color, stroke: "var(--surface)", "stroke-width": 2 }, svg);
}
}
attachHover(svg, spec, X, m, H);
}
function attachHover(svg, spec, X, m, H) {
const tip = svg.nextElementSibling;
const pts = spec.points.filter(Boolean);
const cross = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--axis)", "stroke-width": 1, visibility: "hidden" }, svg);
let idx = -1;
function show(i) {
if (!pts.length) return;
idx = Math.max(0, Math.min(pts.length - 1, i));
const p = pts[idx], x = X(p.x);
cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("visibility", "visible");
tip.replaceChildren(h("div", "t", spec.when(p)));
for (const s of spec.series) {
const r = h("div", "r"), k = h("i", spec.kind === "stack" ? "key box" : "key");
k.style[spec.kind === "stack" ? "background" : "borderColor"] = s.color;
r.append(k, h("b", "", spec.fmtTip(s.get(p))), h("span", "", s.name));
tip.appendChild(r);
}
tip.style.display = "block";
const box = svg.getBoundingClientRect(), panel = svg.parentElement.getBoundingClientRect();
let left = box.left - panel.left + x + 12;
if (left + tip.offsetWidth > panel.width - 8) left = box.left - panel.left + x - tip.offsetWidth - 12;
tip.style.left = Math.max(8, left) + "px";
tip.style.top = (box.top - panel.top + 8) + "px";
}
function hide() { cross.setAttribute("visibility", "hidden"); tip.style.display = "none"; }
function nearest(clientX) {
const box = svg.getBoundingClientRect(), x = clientX - box.left;
let best = -1, bd = Infinity;
pts.forEach((p, i) => { const d = Math.abs(X(p.x) - x); if (d < bd) { bd = d; best = i; } });
return best;
}
svg.onpointermove = (e) => show(nearest(e.clientX));
svg.onpointerleave = hide;
svg.onblur = hide;
svg.onfocus = () => show(pts.length - 1);
svg.onkeydown = (e) => {
if (e.key === "ArrowLeft") { show(idx - 1); e.preventDefault(); }
if (e.key === "ArrowRight") { show(idx + 1); e.preventDefault(); }
if (e.key === "Escape") hide();
};
}
function state(cls, icon, label) {
const s = h("span", "state " + cls); s.append(h("i", "", icon), document.createTextNode(label)); return s;
}
function renderCards(d) {
const cards = document.getElementById("cards");
cards.replaceChildren();
for (const key of ["ttft", "availability", "shed"]) {
const s = d.slo[key], c = h("div", "card");
const target = key === "ttft" ? `${pct(s.target, 0)} of requests get a first token within ${secs(s.threshold_s)}`
: key === "availability" ? `${pct(s.target, 0)} of requests not failed by the server`
: `at most ${pct(1 - s.target, 0)} of requests turned away (429) at rated load`;
c.append(h("h2", "", SLO_NAMES[key]), h("div", "line", "Target: " + target));
c.appendChild(h("div", "value", s.total ? pct(s.compliance, 2) : "–"));
c.appendChild(h("div", "line", s.total ? `${s.good.toLocaleString()} of ${s.total.toLocaleString()} requests` : "No requests yet"));
if (key === "shed") c.appendChild(state("none", "i", "Shown, not judged: the rated load for this server is not measured yet"));
else if (!s.total) c.appendChild(state("none", "–", "No data"));
else c.appendChild(s.met ? state("good", "βœ“", "Met") : state("bad", "βœ•", "Missed"));
if (s.total && key !== "shed") {
const left = Math.max(0, s.budget_left), mtr = h("div", "meter"), fill = h("div");
fill.style.width = (100 * left).toFixed(1) + "%"; mtr.appendChild(fill);
mtr.setAttribute("role", "meter"); mtr.setAttribute("aria-valuenow", (100 * left).toFixed(1));
mtr.setAttribute("aria-valuemin", "0"); mtr.setAttribute("aria-valuemax", "100");
mtr.setAttribute("aria-label", "Error budget left");
c.append(mtr, h("div", "line", s.budget_left > 0 ? `Error budget left: ${pct(s.budget_left, 0)}` : "Error budget used up"));
}
if (s.burn_rate_1h !== null) {
const line = h("div", "line", `Last hour: burn rate ${s.burn_rate_1h.toFixed(1)}Γ— over ${s.requests_1h} requests `);
if (s.fast_burn && key !== "shed") line.appendChild(state("warn", "!", `fast burn (> ${d.fast_burn_threshold}Γ—)`));
else if (s.requests_1h < d.burn_min_requests) line.appendChild(document.createTextNode(`(too few to flag, < ${d.burn_min_requests})`));
c.appendChild(line);
}
cards.appendChild(c);
}
document.getElementById("window").textContent =
`SLO window: rolling 7 days, counted since the last restart (${dur(d.slo_window_s)} so far). ` +
"Burn rate = the last hour's bad share Γ· the budgeted share; a fast burn above " +
`${d.fast_burn_threshold}Γ— would spend ~9% of the 7-day budget in an hour.`;
}
function render(d) {
const v = Object.entries(d.variants)[0] || ["?", {}];
document.getElementById("meta").textContent =
`${v[1].version || v[0]} Β· norm ${v[1].norm_backend || "?"} Β· ${v[1].n_slots || "?"} slots Β· up ${dur(d.uptime_s)}`;
renderCards(d);
renderReplicas(d);
const mins = d.minutes.map((p) => p.covered ? { ...p, x: -p.minutes_ago } : null);
const threshold = d.slo.ttft.threshold_s;
document.getElementById("ref-label").textContent = `SLO: first token ≀ ${secs(threshold)}`;
drawChart(document.getElementById("c-req"), {
kind: "stack", points: mins, total: (p) => p ? p.ok + p.shed + p.failed : 0, ymin: 4, integer: true,
fmt: (v) => Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1),
fmtTip: (v) => (v || 0).toLocaleString(), when: (p) => minLabel(p.minutes_ago),
series: [{ name: "Served", color: "var(--s1)", get: (p) => p.ok },
{ name: "Busy (429)", color: "var(--s2)", get: (p) => p.shed },
{ name: "Server error", color: "var(--s3)", get: (p) => p.failed }] });
drawChart(document.getElementById("c-lat"), {
kind: "line", points: mins, ymin: 0.1,
total: (p) => p ? Math.max(p.ttft_p95_s || 0, p.latency_p95_s || 0) : 0,
ref: { value: threshold, label: "SLO " + secs(threshold) },
fmt: (v) => v < 1 ? Math.round(v * 1000) + " ms" : (Number.isInteger(v) ? v : v.toFixed(1)) + " s",
fmtTip: secs, when: (p) => minLabel(p.minutes_ago),
series: [{ name: "First token p50", color: "var(--s1)", get: (p) => p.ttft_p50_s },
{ name: "First token p95", color: "var(--s2)", get: (p) => p.ttft_p95_s },
{ name: "Whole request p95", color: "var(--s3)", get: (p) => p.latency_p95_s }] });
const slots = v[1].n_slots || 1;
document.getElementById("slots-label").textContent = `${slots} slots`;
const snaps = d.snapshots.map((s) => ({ ...s, x: -s.seconds_ago / 60 }));
drawChart(document.getElementById("c-load"), {
kind: "line", points: snaps, maxGap: 0.5, ymin: slots, integer: true,
total: (p) => p ? Math.max(p.busy_slots, p.queue_len) : 0,
ref: { value: slots, label: `${slots} slots` },
fmt: (v) => Number.isInteger(v) ? String(v) : v.toFixed(1),
fmtTip: (v) => String(v), when: (p) => `${Math.round(p.seconds_ago)} s ago`,
series: [{ name: "Busy slots", color: "var(--s1)", get: (p) => p.busy_slots },
{ name: "Waiting in queue", color: "var(--s2)", get: (p) => p.queue_len }] });
renderTable(d);
}
const REPLICA_STATES = {
ok: ["good", "βœ“", "Ready"], not_ready: ["warn", "!", "Starting"], asleep: ["none", "–", "Asleep"],
down: ["bad", "βœ•", "Down"], misconfigured: ["bad", "βœ•", "Misconfigured"],
};
/* Only the router sends `replicas`; on a single server this panel stays hidden. */
function renderReplicas(d) {
const panel = document.getElementById("replicas-panel");
panel.hidden = !d.replicas;
if (!d.replicas) return;
const t = document.getElementById("replicas");
t.replaceChildren();
const head = h("tr");
for (const c of ["Replica", "State", "Queue", "Slots", "Last poll"]) head.appendChild(h("th", "", c));
t.appendChild(head);
for (const x of d.replicas) {
const known = x.state && (d.polling || x.state === "misconfigured"); // a wrong token doesn't fix itself
const [cls, icon, label] = known ? (REPLICA_STATES[x.state] || ["none", "–", x.state]) : ["none", "–", "Not polled"];
const r = h("tr"), cell = h("td");
cell.appendChild(state(cls, icon, label));
r.append(h("td", "", x.name), cell, h("td", "", known ? String(x.queue) : "–"), h("td", "", x.n_slots ? String(x.n_slots) : "–"),
h("td", "", x.polled_s_ago === null ? "never" : dur(x.polled_s_ago) + " ago"));
t.appendChild(r);
}
}
function renderTable(d) {
const t = document.getElementById("table");
t.replaceChildren();
const head = h("tr");
for (const c of ["Minute", "Requests", "Served", "429", "5xx", "First token p50", "p95", "Whole p95", "Tokens"])
head.appendChild(h("th", "", c));
t.appendChild(head);
for (const p of [...d.minutes].reverse()) {
if (!p.covered) continue;
const r = h("tr");
for (const c of [minLabel(p.minutes_ago), p.requests, p.ok, p.shed, p.failed, secs(p.ttft_p50_s),
secs(p.ttft_p95_s), secs(p.latency_p95_s), p.completion_tokens.toLocaleString()])
r.appendChild(h("td", "", String(c)));
t.appendChild(r);
}
}
async function refresh() {
const banner = document.getElementById("banner");
try {
const r = await fetch("stats/history", { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
last = await r.json();
banner.classList.remove("show");
document.querySelector("main").classList.remove("stale");
render(last);
} catch (e) {
banner.textContent = "Can't reach the server right now (it may be starting or asleep). Retrying every 5 s.";
banner.classList.add("show");
document.querySelector("main").classList.add("stale");
}
}
refresh();
setInterval(refresh, 5000);
let rt;
window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(() => last && render(last), 150); });
</script>
</body>
</html>