The link was printed as text next to an adb command, so enrolling a phone meant getting a 200-character string from one device onto another — which is the step that goes wrong, and it does not have to happen at all. The app registers the echolot:// scheme, so on the phone being enrolled following the link is the entire procedure. The raw string and the adb form stay for every other case: reading it on a laptop, or enrolling a device that is not the one holding the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
416 lines
20 KiB
Go
416 lines
20 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package adminui
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
|
// safe to render device names and finding text that ultimately arrived over a network.
|
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
|
"kb": func(n int64) int64 { return n / 1024 },
|
|
// verdictClass keeps an uploaded string out of the class attribute. The verdict arrives inside
|
|
// a document a device sent us, so interpolating it into markup would be trusting a stranger's
|
|
// text with a place in the stylesheet; mapping through a fixed set costs nothing and closes it.
|
|
"verdictClass": func(v string) string {
|
|
switch strings.ToLower(v) {
|
|
case "green", "yellow", "red", "inconclusive":
|
|
return "v-" + strings.ToLower(v)
|
|
default:
|
|
return "v-unknown"
|
|
}
|
|
},
|
|
// verdictLabel says what the light means rather than what it is called. "yellow" is a colour;
|
|
// "worth a look" is a finding, and the reader is here to act on it.
|
|
"verdictLabel": func(v string) string {
|
|
switch strings.ToLower(v) {
|
|
case "green":
|
|
return "clean"
|
|
case "yellow":
|
|
return "worth a look"
|
|
case "red":
|
|
return "faults found"
|
|
case "inconclusive":
|
|
return "inconclusive"
|
|
default:
|
|
return "not recorded"
|
|
}
|
|
},
|
|
}).Parse(baseHTML))
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
|
data["Page"] = page
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, data); err != nil {
|
|
slog.Error("admin template", "page", page, "err", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
|
|
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
// The visual language is an echo sounder's, which is what the name means: an instrument that emits
|
|
// a ping and reads what comes back. That gives the palette (the colours of a water column rather
|
|
// than a neutral near-black), the type (machine-set, because an instrument's readings are), and
|
|
// the one piece of real ornament — a trace of returns across time on the runs page.
|
|
//
|
|
// No web fonts: the CSP forbids loading anything, and shipping font files with a single Go binary
|
|
// would trade the property that makes this server pleasant to run for a typeface. So the character
|
|
// has to come from treatment — tracking, case, scale, rules — rather than from novel letterforms.
|
|
//
|
|
// Tables become stacked records below 46rem rather than scrolling sideways. That is not a fallback:
|
|
// a sounding log prints as label-and-value pairs, and on a phone that form is easier to read than
|
|
// any table, so the mobile layout is the more faithful one of the two.
|
|
const baseHTML = `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Echolot — {{.Page}}</title>
|
|
<style>
|
|
:root{
|
|
--abyss:#071419; --hull:#0d2028; --raise:#122a34; --rule:#17323d;
|
|
--ink:#dce8ea; --dim:#7d97a1; --trace:#6fc9b4;
|
|
--green:#57ad82; --amber:#cf9b3c; --red:#c25757; --slate:#62767f;
|
|
--mono:ui-monospace,"SF Mono","IBM Plex Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
|
--prose:system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
color-scheme:dark;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--abyss);color:var(--ink);
|
|
font:400 15px/1.55 var(--prose);-webkit-text-size-adjust:100%}
|
|
|
|
/* ---- masthead ------------------------------------------------------------------------ */
|
|
/* Wraps rather than overflows: a rigid row pushes the account and its sign-out button past
|
|
the edge of a phone screen, where they cannot be reached at all. */
|
|
.top{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1.4rem;
|
|
padding:.85rem 1.1rem;background:var(--hull);border-bottom:1px solid var(--rule)}
|
|
.mark{font:600 .95rem/1 var(--mono);letter-spacing:.02em;margin:0;color:var(--ink)}
|
|
.mark span{color:var(--trace)}
|
|
.top nav{display:flex;flex-wrap:wrap;gap:.15rem .9rem}
|
|
.top nav a{font:500 .82rem/1 var(--mono);letter-spacing:.06em;color:var(--dim);
|
|
text-decoration:none;padding:.35rem 0;border-bottom:1px solid transparent}
|
|
.top nav a:hover{color:var(--ink)}
|
|
.top nav a[aria-current]{color:var(--trace);border-bottom-color:var(--trace)}
|
|
.who{margin-left:auto;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;
|
|
font:.78rem/1.3 var(--mono);color:var(--dim)}
|
|
|
|
main{padding:1.1rem;max-width:64rem}
|
|
|
|
/* ---- headings: a graduation mark, like a depth scale ------------------------------- */
|
|
h2{font:500 1.05rem/1.2 var(--mono);letter-spacing:-.01em;margin:1.4rem 0 .2rem;
|
|
padding-left:.7rem;border-left:2px solid var(--trace)}
|
|
h2:first-child{margin-top:0}
|
|
h3{font:500 .8rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
|
color:var(--dim);margin:0 0 .7rem}
|
|
.lede{color:var(--dim);font-size:.9rem;margin:.5rem 0 1rem;max-width:46rem}
|
|
|
|
/* ---- readout: how an instrument prints a value ------------------------------------- */
|
|
.readout{list-style:none;margin:0;padding:0}
|
|
.readout li{display:flex;align-items:baseline;gap:.5rem;padding:.3rem 0;
|
|
font:.85rem/1.4 var(--mono)}
|
|
.readout .k{color:var(--dim);white-space:nowrap}
|
|
/* The dotted leader is how a sounding log runs a label out to its value. It is also the thing
|
|
that lets a label and a number sit on one line at any width without a table. */
|
|
.readout .lead{flex:1 1 auto;min-width:1.5rem;align-self:center;height:1px;
|
|
background:repeating-linear-gradient(90deg,var(--rule) 0 2px,transparent 2px 5px)}
|
|
.readout .v{color:var(--ink);text-align:right;overflow-wrap:anywhere}
|
|
|
|
/* ---- the trace: one bar per run, oldest to newest ---------------------------------- */
|
|
/* The signature, and the only ornament here: an echo sounder draws returns against time, and
|
|
so does this. Rows arrive newest-first, so the strip is reversed in CSS rather than in Go. */
|
|
.trace{display:flex;flex-direction:row-reverse;justify-content:flex-end;align-items:flex-end;gap:2px;
|
|
height:3rem;padding:.7rem .8rem;background:var(--hull);
|
|
border:1px solid var(--rule);border-radius:3px;overflow:hidden}
|
|
.trace i{flex:1 1 3px;min-width:2px;max-width:9px;border-radius:1px;opacity:.9}
|
|
.trace .v-green{height:35%;background:var(--green)}
|
|
.trace .v-yellow{height:65%;background:var(--amber)}
|
|
.trace .v-red{height:100%;background:var(--red)}
|
|
.trace .v-inconclusive{height:22%;background:var(--slate)}
|
|
.trace .v-unknown{height:12%;background:var(--rule)}
|
|
.trace-key{display:flex;flex-wrap:wrap;gap:.3rem .9rem;margin:.45rem 0 0;
|
|
font:.72rem/1 var(--mono);letter-spacing:.05em;color:var(--dim)}
|
|
.trace-key b{font-weight:400;color:var(--dim)}
|
|
.trace-key em{font-style:normal;display:inline-block;width:.5rem;height:.5rem;
|
|
border-radius:1px;margin-right:.35rem;vertical-align:baseline;background:var(--rule)}
|
|
.trace-key em.v-green{background:var(--green)}
|
|
.trace-key em.v-yellow{background:var(--amber)}
|
|
.trace-key em.v-red{background:var(--red)}
|
|
.trace-key em.v-inconclusive{background:var(--slate)}
|
|
|
|
/* ---- records: tables that stack on a phone ----------------------------------------- */
|
|
.rec{border:1px solid var(--rule);border-radius:3px;background:var(--hull);
|
|
padding:.75rem .85rem;margin:.5rem 0}
|
|
.rec-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;
|
|
font:.85rem/1.3 var(--mono);margin-bottom:.35rem}
|
|
.rec-head .id{overflow-wrap:anywhere;color:var(--ink)}
|
|
.rec form{margin-top:.6rem}
|
|
/* Why a check matters is a sentence, so it is set as one — full width under the row rather
|
|
than squeezed into a column, where it would wrap to a ribbon two words wide. */
|
|
.why{font:.85rem/1.5 var(--prose);color:var(--dim);margin-top:.45rem;max-width:52rem}
|
|
.tag{font:.68rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
|
|
padding:.24rem .45rem;border-radius:2px;border:1px solid currentColor;white-space:nowrap}
|
|
.v-green{color:var(--green)} .v-yellow{color:var(--amber)}
|
|
.v-red{color:var(--red)} .v-inconclusive{color:var(--slate)} .v-unknown{color:var(--dim)}
|
|
|
|
/* ---- panels, controls, states ------------------------------------------------------ */
|
|
.narrow{max-width:27rem}
|
|
.panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px;
|
|
padding:.95rem 1rem;margin:.9rem 0;min-width:0}
|
|
.empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem;
|
|
color:var(--dim);font-size:.9rem}
|
|
code,pre,.mono{font-family:var(--mono);font-size:.82rem}
|
|
code{overflow-wrap:anywhere;color:var(--trace)}
|
|
pre{background:#040d11;border:1px solid var(--rule);border-radius:3px;padding:.8rem;
|
|
overflow:auto;max-height:32rem;max-width:100%;color:var(--ink)}
|
|
a{color:var(--trace)}
|
|
button{font:500 .82rem/1 var(--mono);letter-spacing:.05em;background:var(--trace);
|
|
color:#04181a;border:0;border-radius:3px;padding:.55rem .9rem;cursor:pointer}
|
|
.btn{display:inline-block;font:500 .82rem/1 var(--mono);letter-spacing:.05em;
|
|
background:var(--trace);color:#04181a;border-radius:3px;padding:.55rem .9rem;
|
|
text-decoration:none}
|
|
button.plain{background:transparent;color:var(--dim);border:1px solid var(--rule)}
|
|
button.danger{background:transparent;color:var(--red);border:1px solid var(--red)}
|
|
button:hover{filter:brightness(1.08)}
|
|
input{font:.9rem var(--mono);background:#040d11;color:var(--ink);border:1px solid var(--rule);
|
|
border-radius:3px;padding:.55rem .6rem;max-width:100%;width:100%}
|
|
label{display:block;font:.72rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;
|
|
color:var(--dim);margin:.9rem 0 .3rem}
|
|
.err{border:1px solid var(--red);color:var(--ink);background:rgba(194,87,87,.09);
|
|
padding:.6rem .75rem;border-radius:3px;font-size:.9rem}
|
|
.muted{color:var(--dim)}
|
|
form.inline{display:inline}
|
|
:focus-visible{outline:2px solid var(--trace);outline-offset:2px}
|
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
|
|
/* ---- above 46rem the records line up in columns ------------------------------------ */
|
|
@media (min-width:46rem){
|
|
.top{padding:.85rem 1.6rem}
|
|
main{padding:1.6rem}
|
|
.recs{margin:.8rem 0}
|
|
/* Every row shares one grid, so the columns agree across rows without a header or a table. */
|
|
.rec{display:grid;grid-template-columns:minmax(12.5rem,18rem) minmax(0,1fr) auto;gap:.35rem 1.4rem;
|
|
align-items:baseline;background:none;border:0;border-bottom:1px solid var(--rule);
|
|
border-radius:0;padding:.6rem 0;margin:0}
|
|
.rec-head{margin:0;flex-direction:column;align-items:flex-start;gap:.3rem}
|
|
.rec form{margin:0}
|
|
/* Widths follow the content: a device name needs room, a finding count does not. */
|
|
.rec .readout{display:grid;grid-template-columns:1.7fr .9fr .9fr 1.1fr;gap:.15rem 1.2rem}
|
|
.rec .readout li{padding:0}
|
|
.rec .readout .lead{display:none}
|
|
.rec .readout .v{text-align:left}
|
|
.open{white-space:nowrap}
|
|
/* Spans the full row: the sentence is the useful part, not a fourth column. */
|
|
.why{grid-column:1/-1;margin-top:.1rem}
|
|
}
|
|
</style></head><body>
|
|
<header class="top">
|
|
<h1 class="mark">echo<span>lot</span></h1>
|
|
{{if ne .Page "login"}}
|
|
<nav>
|
|
<a href="/"{{if eq .Page "dashboard"}} aria-current="page"{{end}}>overview</a>
|
|
<a href="/devices"{{if eq .Page "devices"}} aria-current="page"{{end}}>devices</a>
|
|
<a href="/runs"{{if eq .Page "runs"}} aria-current="page"{{end}}>runs</a>
|
|
</nav>
|
|
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} · your account{{end}}
|
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
|
</span>
|
|
{{end}}
|
|
</header>
|
|
<main>
|
|
|
|
{{if eq .Page "login"}}
|
|
<h2>Sign in</h2>
|
|
<p class="lede">This server keeps the measurements your devices have uploaded.</p>
|
|
{{if .OIDC}}
|
|
<p><a class="btn" href="/auth/start">Sign in with your identity provider</a></p>
|
|
{{end}}
|
|
{{if .LocalSet}}
|
|
<form method="post" action="/login" class="panel narrow">
|
|
<h3>Break-glass account</h3>
|
|
<label for="u">Username</label>
|
|
<input id="u" name="username" autocomplete="username" value="{{.AdminUser}}">
|
|
<label for="p">Password</label>
|
|
<input id="p" name="password" type="password" autocomplete="current-password">
|
|
<p><button>Sign in</button></p>
|
|
</form>
|
|
{{else}}
|
|
<p class="err">No break-glass account is set. Run
|
|
<code>echolot-server --set-admin-password</code> on the host to create one.</p>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "dashboard"}}
|
|
<h2>{{if .Admin}}This server{{else}}Your account{{end}}</h2>
|
|
<ul class="readout panel">
|
|
<li><span class="k">{{if .Admin}}devices enrolled{{else}}your devices{{end}}</span>
|
|
<span class="lead"></span><span class="v">{{.Devices}}</span></li>
|
|
{{if .Admin}}
|
|
<li><span class="k">linked to an account</span>
|
|
<span class="lead"></span><span class="v">{{.Linked}}</span></li>
|
|
{{end}}
|
|
<li><span class="k">{{if .Admin}}runs stored{{else}}your runs{{end}}</span>
|
|
<span class="lead"></span><span class="v">{{.Runs}}</span></li>
|
|
{{if .Admin}}
|
|
<li><span class="k">server version</span>
|
|
<span class="lead"></span><span class="v">{{.Version}}</span></li>
|
|
{{end}}
|
|
</ul>
|
|
{{if not .Admin}}
|
|
<div class="panel">
|
|
<p>You can see every device you have signed in on, read everything they have uploaded, and
|
|
delete any of it.</p>
|
|
<p class="muted">Enrolling devices, revoking them, and reading other people's uploads need an
|
|
administrator account.</p>
|
|
</div>
|
|
{{end}}
|
|
{{with .SelfTest}}
|
|
<h2>Self-test</h2>
|
|
<p class="lede">What this server can measure from where it stands, checked at startup. A
|
|
capability missing here is missing from every run this server takes part in — so a
|
|
client asking for that measurement gets nothing, rather than a wrong answer.</p>
|
|
<ul class="readout panel">
|
|
<li><span class="k">kernel settings</span><span class="lead"></span>
|
|
<span class="v {{if .SysctlOK}}v-green{{else}}v-yellow{{end}}">{{if .SysctlOK}}as needed{{else}}need attention{{end}}</span></li>
|
|
<li><span class="k">egress path MTU</span><span class="lead"></span>
|
|
<span class="v {{if .MTUOK}}v-green{{else}}v-yellow{{end}}">{{if .MTUOK}}full 1500{{else}}reduced{{end}}</span></li>
|
|
</ul>
|
|
{{if .Sysctls}}
|
|
<h3>Kernel settings</h3>
|
|
<div class="recs">
|
|
{{range .Sysctls}}
|
|
<div class="rec">
|
|
<div class="rec-head"><span class="id">{{.Name}}</span>
|
|
<span class="tag {{if eq .Severity "ok"}}v-green{{else}}v-yellow{{end}}">{{.Severity}}</span></div>
|
|
<ul class="readout">
|
|
<li><span class="k">found</span><span class="lead"></span><span class="v">{{.Got}}</span></li>
|
|
<li><span class="k">wanted</span><span class="lead"></span><span class="v">{{.Want}}</span></li>
|
|
</ul>
|
|
<div class="why">{{.Why}}</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{if .EgressMTU}}
|
|
<h3>Egress path MTU</h3>
|
|
<div class="recs">
|
|
{{range .EgressMTU}}
|
|
<div class="rec">
|
|
<div class="rec-head"><span class="id">{{.Target}}</span>
|
|
<span class="tag {{if .FullMTU}}v-green{{else}}v-yellow{{end}}">{{if .FullMTU}}full{{else}}reduced{{end}}</span></div>
|
|
<ul class="readout">
|
|
<li><span class="k">discovered</span><span class="lead"></span>
|
|
<span class="v">{{if .DiscoveredMTU}}{{.DiscoveredMTU}} bytes{{else}}not measured{{end}}</span></li>
|
|
</ul>
|
|
{{with .Err}}<div class="why">{{.}}</div>{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{else if eq .Page "devices"}}
|
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
|
{{with .Link}}
|
|
<div class="panel">
|
|
<h3>Enrolment link</h3>
|
|
<p class="lede">Single use, valid 24 hours. Treat it like a password until it is spent.</p>
|
|
<!-- On the phone being enrolled this is the whole procedure: the scheme is registered by the
|
|
app, so following the link hands it the token directly. Copying a 200-character string
|
|
between two devices is the step that goes wrong, and it does not have to happen at all. -->
|
|
<p><a class="btn" href="{{.}}">Open in the Echolot app</a></p>
|
|
<p class="muted">Only works on the phone you are enrolling. From anywhere else, copy the link
|
|
into the app's enrolment field, or deliver it over adb.</p>
|
|
<p><code>{{.}}</code></p>
|
|
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
|
|
</div>
|
|
{{end}}
|
|
{{if .Admin}}
|
|
<form method="post" action="/enroll-tokens">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button>Create enrolment link</button>
|
|
</form>
|
|
{{end}}
|
|
{{if .Rows}}<div class="recs">
|
|
{{range .Rows}}
|
|
<div class="rec">
|
|
<div class="rec-head">
|
|
<span class="id">{{if .Name}}{{.Name}}{{else}}{{.ID}}{{end}}</span>
|
|
{{if .LinkedToAccount}}<span class="tag v-green">{{.AccountName}}</span>
|
|
{{else}}<span class="tag v-unknown">no account</span>{{end}}
|
|
</div>
|
|
<ul class="readout">
|
|
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.ID}}</span></li>
|
|
<li><span class="k">enrolled</span><span class="lead"></span>
|
|
<span class="v">{{.Enrolled.Format "2006-01-02 15:04"}}</span></li>
|
|
<li><span class="k">runs</span><span class="lead"></span><span class="v">{{.Runs}}</span></li>
|
|
</ul>
|
|
{{if $.Admin}}
|
|
<form method="post" action="/devices/{{.ID}}/revoke">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button class="danger">Revoke</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>{{else}}
|
|
<p class="empty">{{if .Admin}}No devices yet. Create an enrolment link and open it on the phone
|
|
you want to measure from.{{else}}No devices yet. Sign in from the Echolot app on your phone to
|
|
link one to this account.{{end}}</p>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "runs"}}
|
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
|
<p class="lede">Each run is shown exactly as it arrived, at the privacy level its uploader chose.
|
|
Nothing here can un-redact one.</p>
|
|
{{if .Rows}}
|
|
<div class="trace">{{range .Rows}}<i class="{{verdictClass .Verdict}}"></i>{{end}}</div>
|
|
<p class="trace-key"><b>oldest → newest</b>
|
|
<b><em class="v-green"></em>clean</b>
|
|
<b><em class="v-yellow"></em>worth a look</b>
|
|
<b><em class="v-red"></em>faults</b>
|
|
<b><em class="v-inconclusive"></em>inconclusive</b></p>
|
|
{{end}}
|
|
{{if .Rows}}<div class="recs">
|
|
{{range .Rows}}
|
|
<div class="rec">
|
|
<div class="rec-head">
|
|
<span class="id">{{.UploadedAt.Format "2006-01-02 15:04"}}</span>
|
|
<span class="tag {{verdictClass .Verdict}}">{{verdictLabel .Verdict}}</span>
|
|
</div>
|
|
<ul class="readout">
|
|
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.DeviceName}}</span></li>
|
|
<li><span class="k">findings</span><span class="lead"></span><span class="v">{{.FindingCount}}</span></li>
|
|
<li><span class="k">size</span><span class="lead"></span><span class="v">{{kb .SizeBytes}} kB</span></li>
|
|
<li><span class="k">privacy</span><span class="lead"></span><span class="v">{{.Anonymization}}</span></li>
|
|
</ul>
|
|
<div class="open"><a href="/runs/{{.DeviceID}}/{{.ID}}">Open run</a></div>
|
|
</div>
|
|
{{end}}
|
|
</div>{{else}}
|
|
<p class="empty">Nothing uploaded yet. Take a measurement in the app and upload it; it will
|
|
appear here.</p>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "run"}}
|
|
<h2>Run {{.ID}}</h2>
|
|
<p class="lede">The document as stored, indented for reading. Nothing has been added or removed.</p>
|
|
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button class="danger">Delete this run</button>
|
|
</form>
|
|
<pre>{{.JSON}}</pre>
|
|
{{end}}
|
|
|
|
</main></body></html>
|
|
`
|